React lazy loading 101

When users open an unknown website to read the news or order a new pair of shoes, they don’t want to wait too long. All these “loading,” “parsing,” and “rendering” take too much time, and it’s fair enough for our customers to want to get only the necessary things.

In this small series of articles we will find out how to split a React app into chunks and load them lazily to speed up project initialization.

One thick app

Let’s say you have built a web app for your friend’s online shop. It consists of three not so relative parts: a landing page, the shop itself and a blog. If you have never thought about code splitting then your app may look like this:

A small HTML file, not so big CSS file and a huge JavaScript fileA small HTML file, not so big CSS file and a huge JavaScript file

Your users have to download them all no matter what page or what part of the shop they open. First time user is opening a landing page? Download all the code for shop and blog! Regular customer wants to make an order? Sure, but first download our landing page and blog.

Well, that does not sound like a good app design. Let’s see what we can do.

Many thin apps

One obvious solution is to split your app completely to three separated apps. One for each “sub app.” It sounds fine if those apps do not share code and your app.js looks like this:

Clearly separated three parts of the appClearly separated three parts of the app

But in real life it does not work this way. Usually your JS bundle contains a lot of shared code: framework, third-party libraries, common components following your company style guide, etc:

A bunch of small chunks related to the different parts of the appA bunch of small chunks related to the different parts of the app

Here you can’t just “cut out” the landing page, or blog from the bundle. If you do this, you may break different parts of your app or you have to duplicate dependencies in each sub app.

But there is an elegant solution. Instead of working hard to resolve dependencies by ourselves we can pass this to robots, because it’s their responsibility, not ours. We can mark entry points of our sub apps as “dynamically imported” and let the bundler decide what they should contain to be as small as possible.

Here’s what the bundler might do. When the bundler gets a new dependency it checks:

  1. If this dependency is used in every sub app, then leave it in the main bundle.
  2. Else, if this dependency is used in some sub apps, then move it to the common bundle for those sub apps.
  3. Finally, if this dependency is used in one sub app only, then move it to the bundle for this sub app.

As a result we will get all the small pieces of the original bundle grouped into several medium ones:

Small chunks combined into the bigger ones, that may be loaded lazilySmall chunks combined into the bigger ones, that may be loaded lazily

And now, when users open any part of our service, they download only those pieces of the bundle that are necessary for rendering.

Setting up the project

Let’s create a new project with React and Webpack. To describe code splitting and lazy loading properly we won’t use create-react-app. Instead we build everything from scratch.

Initialize the project and add dependencies:

{
  "name": "uploadcare-react-lazy",
  "version": "0.1.0",
  "dependencies": {
    "@babel/core": "7.14.6",
    "@babel/preset-react": "7.14.5",
    "history": "4.10.1",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "react-router-dom": "5.2.0"
  },
  "devDependencies": {
    "babel-loader": "8.2.2",
    "html-webpack-plugin": "5.3.2",
    "serve": "12.0.0",
    "webpack": "5.44.0",
    "webpack-cli": "4.7.2"
  }
}

Then create a webpack config:

// webpack.config.js

const path = require('path');

const Html = require('html-webpack-plugin');

const resolvePath = x => path.resolve(__dirname, ...x.split(path.sep));

module.exports = {
  entry: {
    main: resolvePath('src/app.js'),
  },

  output: {
    path: resolvePath('public'),
    uniqueName: 'main',
    clean: true,
  },

  mode: 'development',

  bail: true,

  optimization: {
    minimize: false,
  },

  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              '@babel/preset-react',
            ]
          }
        },
      },
    ],
  },

  plugins: [
    new Html({
      template: resolvePath('src/index.ejs'),
    }),
  ],
};

If you don’t understand any webpack setting here, check the official webpack documentation. For now there’s nothing special in this config that relates to code splitting, just a regular setup for React-based SPA.

We have configs, now let’s add the app itself. First, add an HTML template:

<!-- src/index.ejs -->

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>

This template is a pretty basic one.

Then add components that will be shared between sub apps. We will use some of those that were pictured on the figures above: Article, Cart, Comments, Gallery, Header, Input & Parallax. The content of these components does not matter for our example. You are free to bloat them as much as you want to. E.g. we may add a lot of text:

// src/components/article.js

import React from 'react';

export default () => {
  if (Math.random() < .0001) {
    return render();
  } else {
    return <p>Article</p>;
  }
}

function render() {
  return (
    <div>
      Lorem ipsum dolor sit amet... a lot of text here
    </div>
  )
}

We are almost there. To illustrate three parts of our app, let’s add views that depend on the components we’ve just defined. E.g. there’s Blog view:

// src/views/blog.js

import React from 'react';

import Article from '../components/article';
import Comments from '../components/comments';
import Gallery from '../components/gallery';
import Header from '../components/header';

export default () => (
  <>
    <h1>Blog</h1>

    <Header/>
    <Gallery/>
    <Article/>
    <Comments/>
  </>
);

Final touch — let’s glue everything using React Router:

// src/app.js

import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Router, Switch } from 'react-router-dom';
import { createBrowserHistory } from 'history';

import Blog from './views/blog';
import Landing from './views/landing';
import Shop from './views/shop';

export const history = createBrowserHistory({ basename: '/' });

const App = () => (
  <Router history={history}>
    <Switch>
      <Route path="/" exact component={Landing}/>
      <Route path="/blog" exact component={Blog}/>
      <Route path="/shop" exact component={Shop}/>
    </Switch>
  </Router>
);

const appNode = document.getElementById('app');

ReactDOM.render(<App/>, appNode);

Now if we build the app we will get an HTML file, and a JS bundle. Here we go:

$ webpack --no-stats && ls public
index.html
main.js

We have a basic React app. It’s time to split the code to load it lazily! Here’s the plan:

  1. Use React.lazy to import views dynamically.
  2. Use React.Suspense to define a “view loading” state.
  3. Configure webpack to move common chunks of the views into separate JS files.
  4. Analyze the result.

Let’s do this.

Finally, code splitting & lazy loading

Let’s start with wrapping views imports with React.lazy:

// import Blog from './views/blog';
const Blog = React.lazy(() => import('./views/blog'));

// import Landing from './views/landing';
const Landing = React.lazy(() => import('./views/landing'));

// import Shop from './views/shop';
const Shop = React.lazy(() => import('./views/shop'));

This code looks simple, but actually there’s a lot happening behind the scenes. We will discuss the mechanism in the next article.

If we build our project now, we will find three more JS files in the output folder:

$ webpack --no-stats && ls public
index.html
main.js
src_views_blog_js.js
src_views_landing_js.js
src_views_shop_js.js

Each of them represents the part of our app that we’ve dynamically imported using React.lazy. But if we start a dev server, React app won’t be mounted:

$ serve -u -s public
Serving!

A bunch of errors in DevTools console saying that for lazy load we should use React.SuspenseA bunch of errors in DevTools console saying that for lazy load we should use React.Suspense

As you see, React kindly asks us to use React.Suspense. It’s fair, because React does not know what to show to users while bundles are being lazily loaded. In our case we’ll add just a text node:

const App = () => (
  <Router history={history}>
    {/* ↓ here it is ↓ */}
    <React.Suspense fallback={() => <p>Loading...</p>}>
      <Switch>
        <Route path="/" exact component={Landing}/>
        <Route path="/blog" exact component={Blog}/>
        <Route path="/shop" exact component={Shop}/>
      </Switch>
    </React.Suspense>
  </Router>
);

Now everything works as expected:

Browser screenshot with properly working React app that was loaded lazilyBrowser screenshot with properly working React app that was loaded lazily

There’s only one more small detail. When we were drawing the schemes above, we were discussing “common chunks” bundle creation. But for now we only have one main bundle and one bundle for each part of the app:

$ ls public
index.html
main.js
src_views_blog_js.js
src_views_landing_js.js
src_views_shop_js.js

That’s because we haven’t configured webpack and it does not know what to do with those common chunks. To do it we should tune up optimization.splitChunks part of the config. Currently the optimization part looks like this:

module.exports = {
  // ...
  optimization: {
    minimize: false,
  },
  // ...
};

First, let’s add chunks option and set it to 'all':

module.exports = {
  // ...
  optimization: {
    minimize: false,
    splitChunks: {
      chunks: 'all',
    },
  },
  // ...
};

Before webpack optimized only those chunks of JS code that were imported dynamically. Now webpack tries to optimize all the chunks that we have. Let’s build:

$ webpack --no-stats && ls public
index.html
main.js
src_components_comments_js.js
src_components_gallery_js-src_components_header_js.js
src_views_blog_js.js
src_views_landing_js.js
src_views_shop_js.js
vendors-node_modules_history_esm_history_js-node_modules_react-dom_index_js-node_modules_reac-544809.js

Whoosh! A bunch of new bundles have appeared. Because we have not minimized them, we can guess the content of the files by looking at their names:

  1. First new bundle contains the Comments component.
  2. Second contains Gallery and Header components.
  3. The last one contains react-dom, history and other dependencies.

The only thing that bothers us right now is the last bundle with vendor code. We use it in every sub app, so we actually don’t need it to be separated from the main bundle. To disable moving vendor chunks into bundle we may disable defaultVendors cache group:

module.exports = {
  // ...
  optimization: {
    minimize: false,
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        defaultVendors: false,
      },
    },
  },
  // ...
};

Now it works exactly how we want it:

$ webpack --no-stats && ls public
index.html
main.js
src_components_comments_js.js
src_components_gallery_js-src_components_header_js.js
src_views_blog_js.js
src_views_landing_js.js
src_views_shop_js.js

It’s totally up to you to disable or not vendor chunk generation. But we will discuss it in next articles one more time.

Let’s analyze

Without all these optimizations if users opened any page of the app, they downloaded the full bundle:

DevTools network log with 7.3 MB transferred without lazy loading & code splittingDevTools network log with 7.3 MB transferred without lazy loading & code splitting

But now they downloaded only those parts that they need. E.g. when they open Landing Page they download 3.8 MB instead of 7.3 MB:

DevTools network log with 3.8 MB of transferred data with lazy loading & code splittingDevTools network log with 3.8 MB of transferred data with lazy loading & code splitting

Note that we’re comparing bundles without gzip or even minification which may look awkward. We’re doing this because of two reasons:

  1. Our code is full of copy-pasted data, which will be gzipped well and will ruin our comparison.
  2. Due to the lack of minification we can differ bundles by name, analyze their code, etc.

If you develop a real project don’t forget to enable minification and gzip for production bundles. It will reduce TBT and your users will be happier.

Anyway, is it fine to have several bundles instead of one? Should we move dependencies into the separated bundle? Won’t common chunks be downloaded twice during users navigation? How does this lazy loading even work? Answers for all these questions we will find in the next article.

Infrastructure for user images, videos & documents