Code splitting and preloading React routes with Webpack
Development | Domagoj Suhic

Code splitting and preloading React routes with Webpack

Monday, Dec 30, 2019 • 11 min read
An introduction to setting up code splitting on React routes using Webpack with several ways of managing preloading.

If you’ve ever developed a single-page application (SPA), chances are that at least once you’ve had users complaining that the app takes a long time to initially load. You opened up the devtools on your favorite browser and lo and behold the root cause of all evil was staring at you in the eyes - bundle size.

Whether the issue is in the time it takes for the bundle to get from the server to the client or the execution speed of the code inside it, the bundle size will have a big ipact on the initial experience of your users. Yes, it seems like bundling thousands of JavaScript files into one big pile of code might come with some unwanted side-effects.

However, there is no need to despair. We can use code-splitting to solve this problem.

Code splitting to the rescue

Oh, wise master”, you say. “I am willing to sell my soul if you would teach me these dark arts you speak of. Please do not make me lose my components or those sweet, sweet, npm modules!

You are in luck because the only thing this knowledge will cost you is the time required to read this guide.

In essence, code-splitting is just creating several bundles instead of one big bundle that contains all of your code. There are several ways we can approach this, and an experienced user should probably take some time after reading to experiment and find what works best for his apps, but the simplest way is to separate all code necessary used a single route in a separate bundle.

Initial setup

We’ll be using a demo React app with the react-router-dom as our router of choice. Additionally, we’ll also use Webpack for all our bundling needs.

Let’s start by creating a React app. It’s easiest if we use create-react-app. With that done, we already have all of the Webpack options that we need configured. Now it’s time to setup our routes. If you look at the philosophy section of react-router-dom’s guide, you will notice that the usage of static routes is discouraged. However, the latter parts of this guide will depend on the existence of a configuration file which holds the knowledge of all the routes known to man (or more precisely, our demo app). Because of that, we will set the routes up that way from the get-go.

We will create three root route components: Home, ParentA, and ParentB.

// src/pages/parent-a/ParentA.js
// Do the same for Home and ParentB components.

import React from 'react';

const ParentA = () => {
  return <div>This is the ParentA component.</div>;
};

export default ParentA;

Let’s also add some child components to ParentA and ParentB.

// src/pages/parent-a/child-a/ChildA.js
// Do the same for ChildB and the children of ParentB.

import React from 'react';

const ChildA = () => {
  return <div>This is the ChildA component.</div>;
};

export default ChildA;

For setting up the routes, we can do something like this:

// src/pages/parent-a/routes.js
// Do the same for ParentB and Home components.

import ParentA from './ParentA';
import ChildA from './child-a/ChildA';
import ChildB from './child-b/ChildB';

const routes = [
  {
    path: '/a',
    component: ParentA,
    routes: [
      {
        path: '/a/a',
        component: ChildA,
      },
      {
        path: '/a/b',
        component: ChildB,
      },
    ],
  },
];

export default routes;

and then in the main routes configuration file do this:

// src/pages/routes.js

import HomeRoutes from './home/routes';
import ParentARoutes from './parent-a/routes';
import ParentBRoutes from './parent-b/routes';

const routes = [...HomeRoutes, ...ParentARoutes, ...ParentBRoutes];

export default routes;

We can also add some menu components to the pages so that we can have links to the other routes, like this:

// src/components/parent-a/Menu.js
import React from 'react';
import { Link } from 'react-router-dom';

const Menu = () => (
  <div>
    <Link className="link" to="/a/a">
      Child A
    </Link>
    <Link className="link" to="/a/b">
      Child B
    </Link>
  </div>
);

export default Menu;

Finally, we need to render the routes themselves. Notice how we defined child routes as the routes property of the respective parent route? We can use that to recursively render the routes. We could also have used that to avoid prepending the parent route’s path (e.g. use ‘/a’ as the path for ChildA instead of ‘/a/a’), but that is outside the scope of this guide.

// src/components/router/Router.js
import React, { Fragment } from 'react';
import { Switch, Route } from 'react-router-dom';

const Router = ({ routes }) => {
  return (
    <Switch>
      {routes.map(
        ({ path, component: Component, routes: children, ...rest }) => (
          <Route
            key={path}
            path={path}
            component={props => (
              <Fragment>
                <Component {...props} />
                {children && children.length > 0 ? (
                  <Router {...props} routes={children} />
                ) : null}
              </Fragment>
            )}
            {...rest}
          />
        )
      )}
    </Switch>
  );
};

export default Router;

and lastly, in App.js let’s render the router:

import React from 'react';
import { BrowserRouter } from 'react-router-dom';

import routes from './pages/routes';
import { Menu } from './components/menu';
import { Router } from './components/router';

const App = () => {
  return (
    <BrowserRouter>
      <div className="app">
        <header className="app-header">App Root</header>
        <Menu />
        <Router routes={routes} />
      </div>
    </BrowserRouter>
  );
};

export default App;

After running the app, if we inspect the devtools, we can notice that no additional files are being loaded while we are going from route to route. All of our code is being downloaded and initialized immediately. This is not a problem on an app of this size, but if we imagine a much bigger app then loading the components needed on ChildB of ParentB makes no sense if we are on ChildA of ParentA. Enter lazy loading.

Lazy loading

So far we have only been discussing the problem without showing any solutions. Now it’s time for just that. Luckily for us, this step is going to be easy peasy. As a matter of fact, most of the work was done when we ran create-react-app. All we have to do now is somehow tell Webpack that a certain component can be loaded lazily and it will create a separate bundle for it. How do we do it? Dynamic imports.

However, if we just change our import statements for the route components, React won’t know what to do with them. Fear not, though, for the great minds of React’s developers have answered our pleas and granted us the Suspense component and the lazy method. All we have to do is wrap our Route components with Suspense and our dynamic imports with lazy like this:

import React, { Fragment, Suspense } from 'react';
import { Switch, Route } from 'react-router-dom';

const Loader = <div>Loading route...</div>;

const Router = ({ routes }) => {
  return (
    <Switch>
      <Suspense fallback={Loader}>
        {routes.map(
          ({ path, component: Component, routes: children, ...rest }) => (
            <Route
              key={path}
              path={path}
              component={props => (
                <Fragment>
                  <Component {...props} />
                  {children && children.length > 0 ? (
                    <Router {...props} routes={children} />
                  ) : null}
                </Fragment>
              )}
              {...rest}
            />
          )
        )}
      </Suspense>
    </Switch>
  );
};

export default Router;
import { lazy } from 'react';
import ParentB from './ParentB';

const routes = [
  {
    path: '/b',
    component: ParentB,
    routes: [
      {
        path: '/b/a',
        component: lazy(() => import('./child-a/ChildA'))
      },
      {
        path: '/b/b',
        component: lazy(() => import('./child-b/ChildB'))
      }
    ]
  }
];

export default routes;

Now when we initially load the app, we will only get the code for the three parent routes. Then, when go to one of the child routes, we will fetch the necessary code from the server. All is well, the blight has ended, and mankind is now one step closer to transcendence.

Or is it?

*Notices your loader* OwO what’s this?

Imagine this: You’re a person using a web application. You have an above average network connection and your computer is blazingly fast. You’ve been using this web application for some time and you’ve never noticed that it takes too long to render. Then, one day, an update was rolled out promising to fix the noticeably slow initial render for some users. You ignore it. After all, you’ve never had problems before so this doesn’t concern you.

You go to the ParentA route of the app and everything seems the same as before. However, once you click the link to the ChildA route, you are shocked! Are your eyes deceiving you? For a moment it seemed as though there was some sort of loading text or a spinner on your screen. But that can’t be! It’s inconceivable!

What happened?

Well, lazily loading your routes does not come without its own costs. See, if you only start loading the routes when the user wants to access them, it means that no matter how good your network connection is, you’ll still have at least a teeny-tiny segment of time during which the Suspense component will display the provided fallback.

There are several ways we can solve this problem, each with their own pros and cons, but this guide will focus on one of them - preloading.

Preloading

The entire basis for this guide was the premise that it makes no sense to fetch code that will potentially be unused. Considering that preloading literally means loading content before it is needed, it might seem counter-intuitive that it would be the solution to our problem. However, unlike in the original setup, we won’t load everything indiscriminately. Instead, we will try to come up with a solution that adequately predicts which routes we want to preload.

I will present you with two ways of doing just that.

Preloading child routes

It could be argued that if a user visits route ParentA, he’s likely to visit route ChildA or ChildB. So, it might make sense to start preloading all the child routes of the current route. Here’s how you could do it:

// src/pages/parent-a/routes.js
import ParentA from './ParentA';

...
      {
        path: '/a/a',
        // we have changed component to fetchComponent
        // and removed the lazy call
        fetchComponent: () => import('./child-a/ChildA')
      },
...
// src/pages/router/Router.js
...

const Router = ({ routes }) => {
  const [routesWithPreload, setRoutesWithPreload] = useState(routes);
  useEffect(() => {
    setRoutesWithPreload(
      routesWithPreload.map(route => {
        const copy = { ...route };
        const { fetchComponent } = copy;
        if (fetchComponent) {
          const preloadedComponent = fetchComponent();
          copy.component = lazy(() => preloadedComponent);
        }
        return copy;
      })
    );
  }, []);
  return (
    <Switch>
      <Suspense fallback={Loader}>
        {routesWithPreload.map(
          ({ path, component: Component, routes: children, ...rest }) => (
            <Route
              key={path}
              path={path}
              component={props => (
                <Fragment>
                  {/*
                    This is so that we don't get errors
                    if we initialy go to a route that
                    is dynamically imported
                  */}
                  {Component ? <Component {...props} /> : null}
                  {children && children.length > 0 ? (
                    <Router {...props} routes={children} />
                  ) : null}
                </Fragment>
              )}
              {...rest}
            />
          )
        )}
      </Suspense>
    </Switch>
  );
};

...

And that’s it! Now, if we go to route ParentA we will start loading routes ChildA and ChildB before accessing them. (Note: if you don’t like the static route config we’re using in this demo app, this could be accomplished relatively simply with react-router-dom’s dynamic routing as well).

You might have noticed an issue with this approach, though. If not, let’s change one thing in our app and maybe things will be clearer then. Let’s go to the ParentA route’s Menu and change the paths:

import React from 'react';
import { Link } from 'react-router-dom';

const Menu = () => (
  <div>
    <Link className="link" to="/b/a">
      Child A
    </Link>
    <Link className="link" to="/b/b">
      Child B
    </Link>
  </div>
);

export default Menu;

As you can see, we changed the to properties to point to the routes belonging to ParentB. If we now click those links, we will see the same issue we had before. Such transitions are not uncommon so it’s kind of a big deal to solve this issue.

Is there something we can do about it?

Oh, ye of little faith, of course there is. Why else would I write this guide? :)

The other method of preloading that I will show you is preloading routes that have a visible link to them on the page. With the exception of programmatic redirects, this should cover all of your preloading needs. So, how to do it?

Once again, I will show you two ways to accomplish this.

The virgin manual preloading

This should be pretty straightforward. All we have to do is run the dynamic import statements for the required components once the ParentA’s Menu component is mounted.

import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';

const Menu = () => {
  useEffect(() => {
    // if ParentB wasn't included in the main bundle,
    // we would also have to load it
    // import('../../pages/parent-b/ParentB');
    import('../../pages/parent-b/child-a/ChildA');
    import('../../pages/parent-b/child-b/ChildB');
  }, []);
  return (
    <div>
      <Link className="link" to="/b/a">
        Child A
      </Link>
      <Link className="link" to="/b/b">
        Child B
      </Link>
    </div>
  );
};

export default Menu;

This works as expected and is relatively simple. It also gives us a lot of options for customization (we can decide to do this for only certain imports, or do it conditionally etc.). We could also use a similar solution for programmatic redirects. However, it has one flaw which I have a personal distaste for. See, in my perfect world, matching a route path with the component that should be rendered while on it should be done in one place.

Unfortunately, in this example, if we change the location of the ParentB component, we can’t just change the configuration file holding the ParentB routes. Instead, we would have to change it in the Menu component as well.

Now, let us finally see the true strength of static routing.

The Chad automatic preloading

Our goal is to create a wrapper around the Link component that will, upon mounting, start preloading the component that is rendered on the route it’s pointing to. The first order of business is to make the route configuration accessible from such a component. Of course, we could just import it, but that would be a lazy (no pun intended) solution. Also, I’m worried about creating cyclic dependencies.

Instead, we’ll create a context (if you’re using a state management library you’ll probably use whatever state/store object present there) that will provide the routes. Since we will be using those routes for matching their paths to the current path, it might make things easier to also flatten them.

// src/common/utilities/flattenRoutes.js
function flattenRoutes(routes = []) {
  let flattenedRoutes = [];
  routes.forEach(route => {
    flattenedRoutes.push(route);
    if (route.routes) {
      flattenedRoutes = flattenedRoutes.concat(route.routes);
    }
  });
  return flattenedRoutes;
}

export default flattenRoutes;
// src/common/contexts/RouteContext.js
import { createContext } from 'react';

const RouteContext = createContext([]);

export default RouteContext;
// src/App.js
import React from 'react';
import { BrowserRouter } from 'react-router-dom';

import routes from './pages/routes';
import { Menu } from './components/menu';
import { Router } from './components/router';
import { flattenRoutes } from './common/utilities';
import { RouteContext } from './common/contexts';

const flattenedRoutes = flattenRoutes(routes);

const App = () => {
  return (
    <RouteContext.Provider value={flattenedRoutes}>
      <BrowserRouter>
        <div className="app">
          <header className="app-header">App Root</header>
          <Menu />
          <Router routes={routes} />
        </div>
      </BrowserRouter>
    </RouteContext.Provider>
  );
};

export default App;

Now, all that’s left is to create our Link component which should be pretty straightforward since we can use the matchPath method from react-router-dom to match the routes to the target path.

import React, { useContext, useEffect } from 'react';
import { Link as ReactLink, matchPath } from 'react-router-dom';

import { RouteContext } from '../../common/contexts';

const Link = ({ to, ...rest }) => {
  const routes = useContext(RouteContext);
  useEffect(() => {
    // using filter ensures we get all matches, so if we have
    // a link to /a/b/c we'll also get routes /a/b and /a
    const targetRoutes = routes.filter(route => matchPath(to, route));
    targetRoutes.forEach(({ fetchComponent }) => {
      if (fetchComponent) {
        fetchComponent();
      }
    });
  }, [routes]);
  return <ReactLink to={to} {...rest} />;
};

export default Link;

We could customize this further by making this functionality dependent on a boolean prop, but this is it. Now whenever you use this link component, whatever route it’s pointing to will have its components preloaded. Isn’t that great?

Conclusion

Code-splitting and especially preloading are topics that one could write far more about than I did here so there’s a lot that was not covered. For example, we could use traffic data to optimize our preloading strategies, use different preloading strategies depending on the users' device or network speed, preloading when link is hovered etc.

In any case, this is a topic that has loads of material online to read through and plenty of things to experiment with. That’s why I encourage you to take some time and have fun figuring out the best way to include code-splitting and preloading into your app.