Saving Bytes in React Apps with Suspense and Lazy Components
I’ve recently been building a client-side router for React that also abstracts the Suspense API; that is, the router will provide particular components for the current path (i.e. window.location.pathname
), but will also support Suspenseful components out-of-the-box. Unfortunately, it turns out that such an abstraction is not ideal due to its inflexibility, but I would nonetheless like to demonstrate how one can defer the loading of React components using this new feature.
The App
Ipsum.io is a React app that renders variations of the Lorem Ipsum placeholder text commonly used in design and publishing. Each “page” is a React component:
These page components are mapped to particular paths via a Router
component, which provides its consumer with the page for the current path, accessible via a child render prop:
For example: if the current path is '/office'
, then the Page
parameter passed to the child render prop will be pages.Office
. If said path has no associated component, then the element passed via the notFound
prop will be rendered.
The user can navigate between routes using the Link
component; this renders a regular anchor element (i.e. <a />
), but also updates the Router
‘s current page; it’s analogous to the Link
component provided by React Router:
Note that I won’t be covering my router implementation in this article. It’s your standard Context and setState
affair, which is already covered by many excellent resources. However, you are more than welcome to peruse my approach over at the GitHub repository.
The Problem
With this unified bundle, we’re forcing the client to download content that may never be rendered. Being able to defer the downloading of these various lipsums until the user navigates to their associated route would reduce initial JavaScript parse times and thus the time it takes for the app to become interactive. What if we could therefore split our app into respective chunks for each path?
Code splitting in this fashion is nothing new; Webpack and Rollup have provided this out of the box for a while, and there are already various component-based strategies for consuming these bundles with React. However, React now provides first-class primitives to defer the loading of components until they’re absolutely needed.
What is Suspense?
Given the context of this article, it may be tempting to view Suspense as a lazy loading mechanism, but this is inaccurate. Rather, it provides a means of… well… suspending the rendering of an element subtree until a particular operation completes, allowing React to render other parts of your app in the meantime; a fallback will be shown until said operation is fulfilled.
In this example, whenever SomeSuspensefulComponent
is suspended, the fallback
node (this prop supports both React elements and JavaScript primitives such as strings) is rendered within that Suspense boundary (i.e. <React.Suspense />
).
It’s even possible to nest Suspense boundaries:
With Suspense, we can declaratively determine what to show in lieu of a particular component when it needs to be deferred.
How can we Suspend Rendering?
It’s all well and good discussing the concept, but how can we trigger this? Looking into the implementation (as of July 2019) of one of the first-party components that supports Suspense is extremely telling:
Much like an error boundary catching an error, Suspense will catch the Promise
(thenable
) thrown in the pending state and render the fallback until it’s resolved; React handles this by unwinding its internal render stack to the nearest Suspense boundary and then continuing to render the subsequent elements in the tree. Charles Stover’s fetch-suspense Hook throws a Promise
returned by the fetch API in the same way to inform Suspense that it should render the fallback.
Lazy Loading Components with React.lazy
As of the original publication date of this article (July 2019), there is actually only a single component provided by the React team that supports Suspense: React.lazy
.
React.lazy
takes a function that returns a Promise
, which should resolve with a React component. Prior to resolving or rejecting, Suspense can tap into this Promise
as demonstrated above to render a fallback:
Given that dynamic imports return Promise
s, we can take advantage of the aforementioned, configuration-free code splitting provided by the likes of Webpack and Rollup and load these respective ES Module-compliant bundles only when required.
Following this approach, we can update Ipsum.io to take advantage of lazy routing:
Notice how we can also render non-Suspenseful components (the initial page rendered via the /
path) within a Suspense boundary.
As a result of lazy loading, we’re able to reduce the initial bundle size, avoiding the downloading of unused bytes and reducing our initial time to interactive.
Given the simplicity of the app, I’ve opted to exclude any metrics, but I hope that you can see the potential benefits this will introduce to much larger and feature-rich single-page apps.
Summary
Suspending the rendering of various React subtrees is a big win for building slick user experiences, allowing the rest of the parent tree to be processed until certain prerequisite tasks are fulfilled. As Suspense rises in popularity, I’m certain we’ll witness all sorts of exciting and pragmatic usages of this feature, but for now, first-class lazy loading is a strong start.