Introduction to React Code Splitting and Lazy Loading Components

March 1, 2021

If you're considering introducing code splitting or lazy loading to your React app, this article will give you a bird's-eye view of what's going on behind the scenes.

You may have heard of dynamic imports, React.lazy, Loadable Components and React Loadable. What libraries and/or language feature should you use, and are they mutually exclusive?

Dynamic imports

First, let's visit a fairly new JavaScript feature (which is technically still a proposal).

Dynamic import() is a function-like form of import that lets you import a module on demand or conditionally.

import("./utils")
  .then((module) => {
    module.default(); // The default export
    module.doSomething(); // A named export
});

Since import() returns a promise, you can use async/await instead of attaching callback functions (then()):

let module = await import('./utils');

Webpack and dynamic import

Webpack supports dynamic imports out of the box. Consider this example:

// ./src/index.js
console.log("Our bundle has loaded.");

import("lodash").then(( { default: _ } ) => {
    console.log("... and our additional chunk has loaded.")

    _.merge(["a", "b"]);
})

Let's add a Webpack configuration that doesn't contain anything out of the ordinary:

// webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    path: path.join(__dirname, "./dist");
    filename: "[name].js",
  },
};

Due to the dynamic import, bundling the code with Webpack will result in two chunks: your main bundle, and the dynamically imported lodash library.

  • main.js
  • vendors-node_modules_lodash_js.js

If you run main.js in your browser you'll see that vendors-node_modules_lodash_js.js is fetched over the network after main.js has loaded.

Webpack adds its own runtime to your bundle which takes care of imports, including dynamic ones. This runtime contains Webpack's own import() and export() equivalents which piece together all of your code. Even so, the raw unbundled code would work in modern browsers because dynamic imports are already widely supported.

The chunk file names will be less verbose in production mode (mode: "production"). Webpack provides sensible defaults based on performance best practices, like adding a hash of the file's contents to the file name in order to break the browser cache. But you're free to name your chunks however you want. Furthermore, create-react-app provides its own configuration. You don't need to get your hands dirty with Webpack.

React

The React documentation refers to dynamic imports and mentions that the feature is supported by Webpack. You don't need a dedicated code-splitting or lazy-loading library to split your bundle, even if you use React. The challenge, however, is how to split React components. You can't simply add an import() statement at the top level of your code—that would cause the component to be loaded prematurely.

Consider this example which uses React Router:

import HomeScreen from "./screens/HomeScreen";
import AboutScreen from "./screens/AboutScreen";

const App = () => (
  <Router>
    <main>
      <Switch>
        <Route exact path=`/`>
          <HomeScreen>
        </Route>
        <Route path=`/about`>
          <AboutScreen>
        </Route>
      </Switch>
    </main>
  </Router>
);

There's no code splitting going on yet. We can't simply write import("./HomeScreen").then((HomeScreen) => { ... }), because then we'd have to wait for the promise to settle before we render anything.

We don't want to load the components immediately, but clearly some kind of components need to written, imported and rendered. This is where the React code-splitting libraries come in.

First, take a look at this home-made implementation:

class AsyncHome extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      component: () => <div>Loading ...</div>,
    };
  }

  componentDidMount() {
    import("./screens/Home").then((Home) => {
      this.setState({
        component: Home.default,
      });
    });
  }

  render() {
    const Component = this.state.component;
    return <Component />;
  }
}

If you put <AsyncHome> in a parent component, <Home> won't be loaded before <AsyncHome> renders and componentDidMount is triggered. Using the routing example above, this means that <Home> will not load until the route matches the path /.

The approach works, but the implementation is crude and shouldn't be used in production.

Instead, use React.lazy or Loadable Components. These libraries give you all the advantages of code-splitting while taking care of the implementation details, loading states and various edge cases.

Conclusion: You probably want to use React.lazy, which is React's own code-splitting solution. If you need more functionality, or server-side rendering which isn't yet supported by React.lazy, use Loadable Components.

Finally, here's the routing example rewritten using React.lazy:

import { Suspense, lazy } from "react";

const HomeScreen = lazy(() => import("./screens/HomeScreen"));
const AboutScreen = lazy(() => import("./routes/AboutScreen"));

const App = () => (
  <Router>
    <Suspense fallback={<div>Loading ...</div>}>
      <Switch>
        <Route exact path=`/`>
          <HomeScreen />
        </Route>
        <Route path=`/about`>
          <AboutScreen>
        </Route>
      </Switch>
    </Suspense>
  </Router>
);