Lawrence Eagles Senior full-stack developer, writer, and instructor.

React dynamic imports and route-centric code splitting guide

7 min read 2126

react-code-splitting

Editor’s note: This post was updated on 21 March 2022 to include information about Loadable Components and the most recent version of React Router.

Performance optimization is a critical software development milestone for every developer. Having invested quality time into writing great code, adding features, enduring protracted debugging sections, and finally, finishing our masterpieces, we then pick our favorite hosting service and deploy the application to the cloud.

But once we try to host and navigate the application, we immediately notice the load time is high — meaning the app is incredibly slow. At this point, we’ve reached the performance optimization milestone.

As developers, we have the benefit of developing our apps on the fattest server known to mankind: localhost. We handly face any performance issues while developing on localhost, but that’s because there’s a difference between production and development.

While developing on a local server, all our files are hosted from our computer’s port. In React, the port is set to 3000 by default. Because internet connection doesn’t matter while we’re using the local server, we can download all our files and JavaScript bundles incredibly quickly.

However, downloading large files and JavaScript bundles can become a large issue once we go live, especially in places that might not have high-speed internet. There are several performance optimization techniques and tricks to use with React. In this article, we’ll look at how to improve performance using route-centric code splitting.

Benefits of code splitting

One great benefit to using create-react-app is that it gives us code splitting and chunking out of the box. Chunks allow us to separate our codes into bundles, which are a group of related chunks packaged into a single file. Tools such as Create React App, Gatsby, and Next.js use webpack to bundle applications. Bundlers like webpack import all of the application files and merge them into a single bundle.

Some advantages of doing this are:

  • Allowing a user’s browser to download the entire app once so that they can navigate seamlessly without needing another HTTP request
  • Browsers don’t need to require or import any other file because they are all in the bundle. While bundling is often helpful, an app bundle can become incredibly large as the app grows, which means it can boomerang on the app load time

For best practice, web developers code split large bundles into smaller ones because it enables them to lazy load files on demand and improves the performance of the React application.

Below is a snippet of a production build for a React app:

We made a custom demo for .
No really. Click here to check it out.

Code splitting production build chunks.

We can create a production build by running build script npm run build or yarn build — the .js and .css files in the build/static/js and build/static/css directories, respectively.

From the image, we can see that the files are split into different chunks. Create React App achieves this with the [SplitChunksPlugin](https://webpack.js.org/plugins/split-chunks-plugin/) webpack plugin. Let’s break down the code shown above:

  1. Main.[hash].chunk.css represents all the CSS codes our application needs. Note that even if you write CSS in JavaScript using something like styled-components, it would still compile to CSS
  2. Number.[hash].chunk.js represents all the libraries used in our application. It’s essentially all the vendor codes imported from the node_modules folder
  3. Main.[hash].chunk.js is all of our application files (Contact.js, About.js, etc.). It represents all the code we wrote for our React application
  4. Runtime-main.[hash].js represents a small webpack runtime logic used to load and run our application. Its contents are in the build/index.html file by default

However, even though our production build is optimized, there is still room for improvement.
Consider the following image:

Code Spitting Optimized Production Build
Code spitting optimized production build.

Although we could create a production build and deploy the application as is, the image above shows that it can be further optimized.

From the image, we see that main.[hash].chunk.js contains all of our application files and is 1000kB in size. We can also see that when a user visits the login page, the entire 1000kB chunk gets downloaded by the browser. This chunk contains codes the user may never need. Consequently, if the login page is 2kB, a user would have to load a 1000kB chunk to view a 2kB page.

Because the main.[hash].chunk.js size increases as our application grows, larger apps can exceed 1000kB in size, meaning that there can be a dramatic increase in our app load time — and it can perform even slower if the user has poor internet speed. This is why we need further optimization.

The solution to this is to split the main.[hash].chunk.js into smaller chunks, which ensures that when a user visits our page, they only download the chunk of code they need. In this example, the user’s browser should only load the login chunk.

By doing so, we’ll dramatically reduce the number of codes the user downloads during our app’s initial load and boosts our application’s performance. Let’s take a look at how to implement code splitting in the next section.

Implementing route-centric code splitting

To implement code splitting, we’ll combine features from both JavaScript and React. Let’s look at the following techniques:

Dynamic imports

This is a modern JavaScript feature that imports our files almost like a promise.

Before:

import Login from "Pages/Login.js";
import Home from "Pages/Home.js";
import About from "Pages/About.js";
import Contact from "Pages/Contact.js";
import Blog from "Pages/Blog.js";
import Shop from "Pages/Shop.js";

The code snippets above import our files using static import. When webpack comes across this syntax, it bundles all the files together. This is because we want to statically include them together.

After:

const module = await import('/modules/myCustomModule.js');

Unlike static imports, which are synchronous, dynamic imports are asynchronous. This enables us to import our modules and files on demand.
Once webpack comes across this syntax, it immediately starts code splitting our application.

React.lazy()

This React component is a function that takes another function as an argument. This argument calls a dynamic import and returns a promise. React.lazy()handles this promise and expects it to return a module that contains a defaultexport React component.

Before:

import Login from "Pages/Login.js";
/pre>

After:

 

import React, {lazy} from "react";
const Login = lazy(()=> import("Pages/Login"));

The login page is now lazy loaded, ensuring that the Login.js chunk is only loaded when it’s rendered.

React.Suspense()

React.Suspense() allows us to conditionally suspend the rendering of a component until it has loaded. It has a fallback prop that accepts a React element. The React element could be a JSX snippet or a complete component.

When a user visits a page that uses dynamic imports, they may see a blank screen while the app loads the module. Sometimes a user can even get an error, due to dynamic imports being asynchronous. The possibility of this increases if the user has a slow internet connection.

React.lazy() and React.suspense() are used together to resolve this issue.

WhileReact.Suspense suspends the rendering of a component until all its dependencies are lazy loaded, it also displays the React element passed to the fallback props as a fallback UI.

Consider the code below:

import React, { lazy, Suspense } from 'react';

const Hero = lazy(() => import('./Components/Hero'));
const Service = lazy(() => import('./Component/Service'));

const Home = () => {
  return (
    <div>
      <Suspense fallback={<div>Page is Loading...</div>}>
        <section>
          <Hero /> 
          <Service />
        </section>
      </Suspense>
    </div>
  );
}

Here, we lazy load the hero and the service components. These are dependencies of the home component. It needs them to display a complete homepage.

We use the suspense component to suspend the rendering of the home component until dependencies are lazy loaded so a user doesn’t get an error or blank page when they navigate to the homepage.

Now when a component is being lazy loaded, the user is engaged with the fallback UI below:

<div>Page is Loading...</div>

React Router

Choosing where to implement code splitting in an application can be problematic. It’s important to pick locations that will evenly split bundles while not interfering with the user’s experience.

Routes are a great place to begin.

The react-router-dom library supports inbuilt route-level code splitting. It allows us to download chunks at the route level. Using this feature, we’ll code split at the route level, which is tremendously helpful.

Consider the below code:

import { lazy, Suspense } from "react";
import { Routes, Route, Outlet, Link } from "react-router-dom";

import HomePage from "./pages/Home";

const Dashboard = lazy(() => import("./pages/Dashboard"));
const Notifications = lazy(() => import("./pages/Notifications"));

export default function App() {
  return (
    <div className="App">
      <h1>React Router Code Splitting Demo</h1>
      <Routes>
        <Route path="/" element={<AppLayout />}>
          <Route index element={<HomePage />} />
          <Route path="dashboard" element={<DashboardPage />} />
          <Route path="notifications" element={<NotificationsPage />} />
        </Route>
      </Routes>
    </div>
  );
}

const DashboardPage = () => (
  <Suspense fallback={<div>Page is Loading...</div>}>
    <Dashboard />
  </Suspense>
);

const NotificationsPage = () => (
  <Suspense fallback={<div>Page is Loading...</div>}>
    <Notifications />
  </Suspense>
);

const AppLayout = () => {
  return (
    <div>
      <nav>
        <ul>
          <li>
            <Link to="/">Home</Link>
          </li>
          <li>
            <Link to="/dashboard">Dashboard</Link>
          </li>
          <li>
            <Link to="/notifications">Notifications</Link>
          </li>
        </ul>
      </nav>
      <hr />
      <Outlet />
    </div>
  );
};

From this code sample, we set up our routes using the react-router-routerlibrary, and the Dashboard and Notifications components are lazy loaded.

Notice how the Dashboard and Notifications components are encapsulated with the Suspense component. This ensures that a fallback UI is rendered to the user while the requested page components are lazy loaded.

Because of our setup, webpack chunks our code ahead of time. Consequently, the user receives only the chunks necessary to render a page on demand. For example, when a user visits the notifications page, the user receives the Notifications``.js chunk, and when users visit the dashboard page, they’ll see the Dashboard``.js chunk.

The complete code and working example of the demo are both available in this codesandbox.

You can see that we have significantly reduced our application’s initial load time, even without reducing the amount of code in our app.

Additionally, you can check out this guide if you are interested in learning more about React Router v6.

Loadable Components

You can also use the Loadable Component library for dynamically loading the page components.

The loadable() function is used to create an async component that can be imported dynamically. It is similar to React.lazy() but additionally it can accept fallback without the need of Suspense component. The loadable() function can also inject props from the component and support full dynamic imports.

import { Routes, Route, Outlet, Link } from "react-router-dom";
import loadable from "@loadable/component";

import HomePage from "./pages/Home";

const LoadablePage = loadable((props) => import(`./pages/${props.page}`), {
  fallback: <div>Page is Loading...</div>,
  cacheKey: (props) => props.page
});

export default function App() {
  return (
    <div className="App">
      <h1>React Router Code Splitting Demo</h1>
      <Routes>
        <Route path="/" element={<AppLayout />}>
          <Route index element={<HomePage />} />
          <Route path="dashboard" element={<LoadablePage page="Dashboard" />} />
          <Route
            path="notifications"
            element={<LoadablePage page="Notifications" />}
          />
        </Route>
      </Routes>
    </div>
  );
}

You can find the demo for loadable``() components in this codesandbox link.

Please note that the loadable``() component library is not an alternative to React.lazy() and React.Suspense and should only be used in case you feel limited or need server-side rendering support. You can check out this comparison guide for detailed information.

Conclusion

In this article, we explained what route-centric code splitting is and why it’s helpful to use. We also discussed leveraging dynamic imports, React.lazy(), React.Suspense, React Router, and Loadable Components to create a better performing React application.

Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — .

Lawrence Eagles Senior full-stack developer, writer, and instructor.

2 Replies to “React dynamic imports and route-centric code splitting guide”

  1. Under Section 4 ‘Router’,

    I guess you mismatched the component with exact path ‘/’.

    Supposed to be…

    “`

    <Suspense fallback={Page is Loading…}>

    “`

Leave a Reply