Borislav Hadzhiev
Mon Mar 08 2021·7 min read
Photo by Gustavo Zambelli
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.
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'));
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.
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.
ErrorBoundary
component is a class, at the time of writing you can only
implement error boundaries using classes.React.lazy
currently only supports default exports.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.