4-Dec

React, Development

Break your app into pieces: Code splitting and lazy loading without the tears

Code splitting and lazy loading can be synonymous with heartache and headache. Luckily, smart people have made tools to make it easier for us. Webpack and React Router are familiar to many in the React community. Add loadable-components to the mix, and you have all the ingredients for code splitting and lazy loading without the tears!

8 min read

·

By Andreas Hammer Håversen

·

December 4, 2023

The state of React apps ⚛️⏳

Many React apps started life as Single Page Applications (SPAs), especially in the before-times, when the recommended way to get started with React was to scaffold your app with create-react-app or to do it yourself with a custom Webpack config. Since then, the community has largely eschewed SPAs and Client Side Rendering (CSR) for Serverside Rendering (SSR) and Multipage-like Apps. Remix and Next.js beeing at the forefront of this change in opinion has brought several benefits to those projects that can successfully build (or indeed have been rebuilt) on those technologies, amongst them efficent code splitting and lazy loading of the components and pages the app consists of.

One of the biggest pains of working with traditional SPAs, wether client or server rendered, is that every new feature, each new page and component, any logic you add or CSS you fiddle with, will invariably be bundled into the same massive JavaScript and CSS bundle, growing fatter and fatter every time you deploy. This is fine at first, when your app has just a handful of pages, but as your business and app grows, you'll eventually find yourself shipping several megabytes of JavaScript and CSS to every visitor to your website.

This hurts. A lot. Not just your users, but your business and the planet as well. Large bundles mean long load times. Long load times frustrates your users, and lead to higher bounce rates. Bad performance also hurts your SEO rankings, as Google downranks pages that score badly on Core Web Vitals metrics. Finally, the large bundle sizes also hurt the planet. Every megabyte transferred on the wire caues on average 0,32g of CO2 emissions. Though that might not sound like much, it adds up quickly. If your site ships a just a megabyte of data and attracts more than 3000 visitors per day, you'll cause a kilogram of CO2 emissions per day. In a month, ~30 kg. In a year, ~360 kg.

So what's a poor developer to do? Well, luckily for you, and indeed all of us, you most likely already have the tools you need to deal with this, easily and without tears.

Webpack magic 🧙‍♂️✨

Webpack is an amazing piece of tech, despite provoking the ire of many developers struggling to configure it correctly. Luckily, code splitting with Webpack isn't something you need to configure your tears out over. Getting started with code splitting is as easy as replacing this:

import largeModule from "./largeModule"

module.foo()
// Boo, I get bundled together with this file

With this:

const largeModule = await import('./largeModule')

module.foo()
// Yay, I'm in my own bundle!

This is a dynamic import, a part of the ECMAScript standard. Instead of importing the module synchronusly, we import it asynchronusly. Webpack will see our dynamic import, and since we can await its load (i.e. Webpack can determine that we don't need it immediately when the script starts) Webpack will automatically split it out in its own module when compiling. Whats more, the module won't actually be downloaded until the import call is made. Like magic!

Code splitting and lazy loading in React 🔪💤🧑‍💻

"That's great and all, but how do I make use of it in my React app?" you may ask at this point. And it's a fair point. Using bare dynamic imports doesn't help you much when what we're trying to code split is React Components. For this, the React team has you covered, with React.lazy. This will wrap your dynamic import, and provide a suspendable component for you to render in your app. So, instead of doing this:

import FooComponent from './FooComponent';

export const BarComponent = () => {
  return <FooComponent />
}

You would write this:

import { lazy, Suspense } from 'react'

const FooComponent = lazy(() => import('./FooComponent'))

export const BarComponent = () => {
  return (
    <Suspense fallback={<span>Loading...</span>}>
      <FooComponent />
    </Suspense>
  )
}

Et voila! Our component is code split thanks to the dynamic import, and lazily loaded thanks to the lazy component definition. This will work for any default exported component in your app! Webpack creates a separate bundle for the component file, and React automatically handles downloading the component bundle at the appropriate time when the component is rendered. The Suspense fallback will be rendered while the component loads.

Lift it up to the routes 🆙🧭

This is where the real magic happens. Most medium or large React apps use some type of routing library for rendering based on the current browser URL. It is therefore often sensible to start splitting your app at the route level, splitting each route into its own bundle. Let us for this example assume that you are using React Router. Then a minimal setup could look like this:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </Suspense>
  </Router>
);

This will leave you with at least three bundles: One for each of the two pages, and one so called critical bundle that will be downloaded for all pages. The critical bundle is the bundle that all pages of your app needs to load in order to function. In this case, that would be the bundle containing the App component, since all other routes are a child of it.

Now, why "at least three components"? As we mentioned before, Webpack is a pretty clever piece of technology. Say we have multiple routes, and several shared components imported into some, but not all of the pages. It would be inefficient to duplicate these components between the bundles that use them, or to include them in the critical bundle when not all bundles need them. Webpack will pick up on this, and will automatically split those shared components out into their own bundles! Whats more, Webpack will also do this for any libraries that are shared across dynamically imported modules. Pure magic!

What this means, is that just introducing lazily loaded and dynamically imported routes in your application will not just code split your routes, but your components and NPM libraries as well. This can give a drastic reduction in the size of the critical bundle, cutting it in half with just route level splitting.

SSR, loadable-components and React Router 6 🤔

The examples so far use the built in React.lazy for code splitting. Whilst that should work just fine with most CSR apps, you may want to consider using loadable-components if you are doing SSR. Its API is quite similar to React.lazy, but does not need Suspense. With the example from before:

import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import loadable from '@loadable/component'

const Home = loadable(() => import('./routes/Home'));
const About = loadable(() => import('./routes/About'));

const App = () => (
  <Router>
      <Routes>
        <Route path="/" Component={Home} />
        <Route path="/about" Component={About} />
      </Routes>
  </Router>
);

Notice that the API is more or less identical, but doesn't need the Suspense wrapper. Suspense works with SSR in React 18 using one of the new streaming rendering methods, but does not work with older versions of React. That makes loadable-components your only option if you are doing SSR in React 17 or older.

Additionally, loadable-components has some extra bells and whistles for SSR, allowing us to extract prefetch and preload tags to put in the HTML head, speeding up page load by loading these resources immediately as the browser receives the HTML from the server.

If you are using React Router 6, you may have noticed that they now allow a lazy prop on routes, making those lazy loaded. That can also work well, but doesn't have a facility for preloading the neccesary bundles for each page. You'll want a way to split individual components anyway, making loadable-components a good bet here too.

If you end up using loadable-components with React Router 6, you can benefit from tweaking the loaders for those components a bit. Say you define your routes like this, in an array:

const routes = [
  {
    path: '/',
    Component: loadable(() => import('./Home'))
  },
  {
    path: '/page',
    Component: loadable(() => import('./Page'))
  },
]

Doing it this way will cause a blank page to render during navigation, while loadable-component is downloading the page component. That is a less than ideal user experience, but can be solved by adjusting the route definition a bit. Recall that route navigation in React Router 6 will suspend until any loaders on the next route complete. Also, loadable-components exposes a method to initiate and await the load of a component. We can use this to ensure that the next route is completely loaded before navigation happens, like so:

const lazyRoute = (loadableComponent) => {
  return {
    Component: LazyComponent,
    loader: async () => {
      await LazyComponent.load();
    }
  };
}

const routes = [
  {
    path: '/',
    ...lazyRoute(loadable(() => import('./Home')))
  },
  {
    path: '/page',
    ...lazyRoute(loadable(() => import('./Page')))
  },
]

Here, we have simply wrapped each loader call in a function that returns the component to React Router, as well as a loader that will call and await the component load. No more white flashes, no more loading spinners!

Get your scissors ready! ✂️👯

As we have seen, code splitting and lazy loading a React app need not be a tearful affair. In fact, it can be quite fun chasing an ever smaller bundle size! This blog post is just scratching the surface of what can become a rather deep rabbit hole, should you dig deep enough. I speak from experience, having code split a large SSR-driven React app down to less than a third of its original size. So, armed with the scissor that is code splitting in React, go forth and split your app into a thousand tiny pieces. Speed up your website, improve your SEO ranking, and save the world a bit with every page load.

Happy code splitting!