How to split your code with React lazy

avatar

Borislav Hadzhiev

Mon Mar 08 20217 min read

React.lazy - why #

The lazy function lets you import components dynamically. You would want to do that to decrease the initial bundle size your users have to download in order to see content on the screen.

Let's say that your application is split into a couple of routes. You have /home route and a /large-page route. The /large-page route imports and uses some large library that you don't use on the /home page. If a user goes to your /home page, you wouldn't want them to have to download the large library in order to render content on the screen, after all - they might not even visit your /large-page route so that would be a waste.

What you would have happen is load just enough javascript to render the /home route for a fast initial render, then if the user navigates to the /large-page route you would display a loading spinner to inform the user that some transition is about to take place and load in the javascript chunks required for the /large-page route.

Most people on the internet are used to having to wait for transitions when navigating between pages. A worse user experience would be for our users to look a white blank screen for a prolonged period of time.

So let's look at how React.lazy could help us handle this.

Example #

Let's create a react app:

npx create-react-app react-lazy --template typescript cd react-lazy npm install react-router-dom npm install moment npm install --save-dev @types/react-router-dom npm start

You only need the index.tsx and App.tsx files, you can delete the .css and .test files.

Let's look at the content of src/index.tsx

// src/index.tsx import React from 'react'; import ReactDOM from 'react-dom'; import {App} from './App'; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById('root'), );

And the content of src/App.tsx:

// src/App.tsx import {BrowserRouter as Router, Link, Route, Switch} from 'react-router-dom'; import Home from './Home'; import LargePage from './LargePage'; export function App() { return ( <Router> <div> <Link to="/">Home</Link> <hr /> <Link to="/large-page">Large Page</Link> <hr /> </div> <Switch> <Route exact path="/"> <Home /> </Route> <Route exact path="/large-page"> <LargePage /> </Route> </Switch> </Router> ); }

First we import some components from react-router-dom and then some local components, that we have not yet written and then we have a simple navigation with 2 links and our components at the / and /large-page routes. Now let's add the components. First let's create the src/Home.tsx component:

// src/Home.tsx export default function Home() { return <h1>This is the home page...</h1>; }

and the src/LargePage.tsx component:

// src/LargePage.tsx import * as moment from 'moment'; export default function LargePage() { const a = moment.duration(-1, 'week').humanize(true, {d: 7, w: 4}); // a week ago return ( <div> <h1>{a}</h1> </div> ); }

In our LargePage component we import the moment library and use it, however we didn't need the moment library in our Home route. Now we can navigate between the routes, but even if the user never went to the LargePage route they would still have to download the moment library on the initial render.

Let's now change this behavior, we only want the user to download the moment library and the component code for the LargePage route if they navigate to the route. Let's edit our src/App.tsx to:

// src/App.tsx - import Home from './Home'; - import LargePage from './LargePage'; + import {lazy} from 'react'; + const Home = lazy(() => import('./Home')); + const LargePage = lazy(() => import('./LargePage'));

Adding the Suspense boundary #

If you now look at the browser you should see a big error.

Error: A react component suspended while rendering, but no fallback UI was specified. Add a Suspense fallback= component higher in the tree to provide a loading indicator or placeholder to display.

So react tells us that a component "suspended", but we didn't provide a loading component to render while the that component was suspended. Suspended means that a component wasn't yet ready to render, because a requirement it has wasn't yet fulfilled. Go to the Home route, open the devtools, select the network tab and filter for JS files, you can see that we have a separate JS chunk for the Home route, because we're lazily importing the Home component. React tries to render it and it's not yet loaded, so the component suspends and a loading state fallback component has to be shown, but we didn't provide one.

As a side note, the Home page component is so small, we shouldn't load it lazily, an extra network request for a module of size 1Kb is a waste, we did it just for the example.

Suspense lets your components wait for something before they can render, showing a fallback while waiting. Let's see how this works in action, edit your src/App.tsx page again and change it to this:

// src/App.tsx import {lazy, Suspense} from 'react'; import {BrowserRouter as Router, Link, Route, Switch} from 'react-router-dom'; const Home = lazy(() => import('./Home')); const LargePage = lazy(() => import('./LargePage')); export function App() { return ( <Router> <div> <Link to="/">Home</Link> <hr /> <Link to="/large-page">Large Page</Link> <hr /> </div> {/* Now wrapping our components in Suspense passing in a fallback */} <Suspense fallback={<h1>Loading...</h1>}> <Switch> <Route exact path="/"> <Home /> </Route> <Route exact path="/large-page"> <LargePage /> </Route> </Switch> </Suspense> </Router> ); }

All we had to do is wrap our components in a Suspense boundary and pass in a fallback for the loading state.

If you open up your network tab in the devtools, set the network speed setting to Slow 3G and refresh the page you should be able to see our fallback being rendered to the page, while the JS chunk of the Home route is being loaded.

Alternatively you could manually suspend the component if you have the react devtools extension installed in your browser. Click on the Components tab in the react extension, select the Home component and click on the stopwatch icon in the upper right corner to suspend the selected component.

So far so good, now let's open the network tab again, filter by JS files and navigate to the Large Page route. You'll see that we load in 2 JS chunks, one is for the component itself and the other one for the moment library.

At this state our application lazily loads the moment library when we navigate to the Large Page route. If a user goes to our Home page and never visits the Large Page they won't even have to load in the moment library or the code for the Large Page component.

As a side note the user only has to load the JS chunks one time. If they were to navigate back and forth, the browser would have already cached the files and we would not see the fallback loading spinner, the components would not have to suspend.

Adding an Error boundary #

Our application seems to be at a good state, however we're using the network to request the JS files we've split, so what would happen if the user loads our Home, loses connection to the internet and navigates to the LargePage route.

To test this go to the Home page, refresh, open your Network tab and set the network status to offline. Now navigate to the /large-page route and you should see a blank white screen, which is never a good thing.

In our console we get the following errors:

Uncaught ChunkLoadError: Loading chunk 2 failed.

So we tried to load in the JS chunk, but we failed and our entire application crashed. In order to provide some feedback to the user and log the error so we can fix it, we have to wrap our components that could potentially throw in an ErrorBoundary component.

ErrorBoundary is like a try{} catch(){} for errors thrown in the render method of components below it.

A good way to think about it is: the Suspense boundary shows a FallbackComponent when its children are not yet ready to render - it handles the Loading State, whereas ErrorBoundary handles the errors thrown in your components' render methods.

Let's add an ErrorBoundary component at src/ErrorBoundary.tsx:

// src/ErrorBoundary.tsx import React from 'react'; export class ErrorBoundary extends React.Component< {children?: React.ReactNode}, {error: unknown; hasError: boolean} > { state = {hasError: false, error: undefined}; componentDidCatch(error: any, errorInfo: any) { this.setState({hasError: true, error}); } render() { if (this.state.hasError) { return <h1>An error has occurred. {JSON.stringify(this.state.error)}</h1>; } return this.props.children; } }

And let's use it in our src/App.tsx component:

// ... other imports import {ErrorBoundary} from './ErrorBoundary'; // ... export function App() { return ( <Router> <div> <Link to="/">Home</Link> <hr /> <Link to="/large-page">Large Page</Link> <hr /> </div> {/* Now wrapping our components in Suspense passing in a fallback */} <ErrorBoundary> <Suspense fallback={<h1>Loading...</h1>}> <Switch> <Route exact path="/"> <Home /> </Route> <Route exact path="/large-page"> <LargePage /> </Route> </Switch> </Suspense> </ErrorBoundary> </Router> ); }

Now we have wrapped our components that could potentially throw with an ErrorBoundary. Let's repeat the test: refresh on the Home page, open your network tab, set the network setting to offline and navigate to the /large-page route. You will see an error being printed to the screen, which is a better behavior than seeing a blank white screen.

Limitations #

  • The ErrorBoundary component is a class, at the time of writing you can only implement error boundaries using classes.
  • The components that we loaded lazily were default exports - React.lazy currently only supports default exports.

Summary #

React.lazy allows us to split our code into chunks. To improve performance for large applications we don't want to force the users to download a single JS file, containing our whole application, because chances are they won't use our whole application, they won't visit every route of our site.

Since most people on the internet are used to having to wait between page transitions, providing a loading indicator and loading the component code on demand if and when the user navigates to it is better than having all users load code they might not need.

Add me on LinkedIn

I'm a Web Developer with TypeScript, React.js, Node.js and AWS experience.

Let's connect on LinkedIn

Join my newsletter

I'll send you 1 email a week with links to all of the articles I've written that week

Buy Me A Coffee