title: Lazy Loading in React Router 6.4
date: '2023-03-26'
tags: ['react', 'blog translation']
draft: false
summary: ''
Original Article: Lazy Loading Routes in React Router 6.4+
Lazy Loading Routes in React Router 6.4+#
React Router 6.4 introduced the concept of "Data Routers" with a focus on separating data fetching from rendering to eliminate render + fetch chains and the accompanying loading spinners.
These chains are often referred to as "waterfalls," but we're rethinking that term because most people hear "waterfall" and think of Niagara Falls, where all the water falls into one big beautiful waterfall. But what we actually want to avoid looks more like the header image above and is more like stairs. Water flows down a bit, then stops, then down a bit more, then stops, and so on. Now imagine a loading spinner at each step of that staircase. That's not the kind of UI we want to give our users! So, in this post (and hopefully beyond), we use the term "chains" to represent the inherent order of fetching, with each fetch being blocked by the fetch before it.
Render + Fetch Chains#
If you haven't read the article "Remixing React Router" or watched Ryan's "When to Fetch" talk from last year's Reactathon, I recommend checking those out before diving into this post. They cover a lot of the background behind the introduction of the data router concept.
In short, when your router doesn't know about your data needs, you end up with link requests and subsequent data needs "discovered" as you render child components.
But introducing a data router allows you to fetch data in parallel and render everything at once:
To achieve this, the data router extracts your route definitions from the render cycle so that our router can identify nested data needs ahead of time.
// app.jsx
import Layout, { getUser } from `./layout`
import Home from `./home`
import Projects, { getProjects } from `./projects`
import Project, { getProject } from `./project`
const routes = [
{
path: '/',
loader: () => getUser(),
element: <Layout />,
children: [
{
index: true,
element: <Home />,
},
{
path: 'projects',
loader: () => getProjects(),
element: <Projects />,
children: [
{
path: ':projectId',
loader: ({ params }) => getProject(params.projectId),
element: <Project />,
},
],
},
],
},
]
But there's a downside to this as well. So far, we've talked about optimizing data fetching, but we also have to consider optimizing the fetching of JS bundles! With the above route definition, while we can fetch all the data in parallel, we're blocking the start of data fetching because we have to download the JavaScript bundle that contains all the loaders and components.
Consider a user entering your site at the / route:
Can React.lazy save us?#
React.lazy provides a great primitive for chunking the component tree, but it suffers from the same issue of being tightly coupled with the fetching and rendering that the data router is trying to eliminate 😕. This is because when you use React.lazy(), you create an async chunk for your component, but React doesn't start fetching that chunk until the lazy component is rendered.
// app.jsx
const LazyComponent = React.lazy(() => import('./component'))
function App() {
return (
<React.Suspense fallback={<p>Loading lazy chunk...</p>}>
<LazyComponent />
</React.Suspense>
)
}
So while we can leverage React.lazy() in the data router, we end up introducing a chain for downloading the components. Ruben Casas wrote a great article that covers some approaches to code splitting with React.lazy() in the data router. But as you can see from that article, manually splitting the code is still a bit verbose and cumbersome. Because the DX isn't great, we received a suggestion from @rossipedia (and an initial POC implementation). This suggestion nicely summarizes the challenges we're facing and gets us thinking about how to introduce first-class code splitting support in the RouterProvider. Huge kudos to both of them (and other awesome community members) for actively participating in the evolution of React Router 🙌.
Introducing Route.lazy#
If we want lazy loading to play nicely with the data router, we need the ability to lazily import outside of the render cycle. Just as we extracted data fetching from the render cycle, we also want to extract route fetching.
If you take a step back and look at the route definition, it can be broken down into three parts:
- Path matching fields, such as path, index, and children
- Data loading/submission fields, such as loaders and actions
- Rendering fields, such as elements and error elements
What the data router really needs on the critical path is the path matching fields because it needs to be able to identify all the routes that match a given URL. Once matched, we already have async navigation in progress, so there's no reason we can't fetch the route information during that navigation. Then, we don't need anything on the rendering side until the data fetching is complete because we don't want to render the target route until the data fetching is done. Yes, this might introduce the concept of a "chain" (load the route, then load the data), but it's an optional lever we can pull to address the tradeoff between initial load speed and subsequent navigation speed when needed.
Here's an example using the above route structure and the new lazy() method used within a route definition (available in React Router v6.9.0):
// app.jsx
import Layout, { getUser } from `./layout`;
import Home from `./home`;
const routes = [{
path: '/',
loader: () => getUser(),
element: <Layout />,
children: [{
index: true,
element: <Home />,
}, {
path: 'projects',
lazy: () => import("./projects"), // 💤 Lazy load!
children: [{
path: ':projectId',
lazy: () => import("./project"), // 💤 Lazy load!
}],
}],
}]
// projects.jsx
export function loader = () => { ... }; // formerly named getProjects
export function Component() { ... } // formerly named Projects
// project.jsx
export function loader = () => { ... }; // formerly named getProject
export function Component() { ... } // formerly named Project
Are you exporting functional components? The properties exported from this lazy module are added verbatim to the route definition. Because exporting an element is weird, we added support for defining components on the route object instead of elements (but don't worry, elements can still be used!).
In this case, we chose to leave the layout and home routes in the main bundle since those are the most common entry points for our users. However, we've moved the project import and route imports into their own dynamic imports that won't be loaded unless navigating to those routes.
On initial load, the resulting network graph looks something like this:
Now our critical path bundle only includes the routes we consider most critical for entering the site. Then, when the user clicks a link to /projects/123, we fetch those routes in parallel using the lazy() method and execute the loader methods they return:
This gives us a bit of the best of both worlds, as we're able to bundle the critical path with the relevant home routes. Then, on navigation, we can match the path and fetch the necessary new route definitions.
Advanced Usage and Optimizations#
Some keen readers might have some 🕷️ Spider-Sense tingling, thinking there's something fishy going on here. Is this the optimal network graph? Turns out, it's not! But considering how little code we wrote to get it, it's pretty darn good 😉.
In the example above, our route module includes both our loader and component, meaning we need to download both before starting the loading process. In reality, in a React Router SPA, your loaders are usually very small and access external APIs where most of the business logic lives. On the other hand, components define the entire user interface, including all the user interactions associated with it—they can get quite large.
Blocking the loader (which might be making fetch() calls to an API) from downloading a large component tree with JS seems silly. What if we could turn this 👆 into this 👇?
The good news is, you can achieve this with minimal code changes! If you statically define a loader/action on a route, it will execute in parallel with the lazy() call. This allows us to decouple loader data fetching from component chunk downloading by splitting loaders and components into separate files:
const routes = [
{
path: 'projects',
async loader({ request, params }) {
let { loader } = await import('./projects-loader')
return loader({ request, params })
},
lazy: () => import('./projects-component'),
},
]
Any fields statically defined on the route will always take precedence over anything returned from lazy. So while you shouldn't define both a static loader and a loader returned from lazy, if you do, the lazy version will be ignored and you'll get a console warning.
This concept of statically defined loaders also opens up some interesting possibilities for directly inline code. For example, maybe you have a separate API endpoint that knows how to fetch data for a given route based on the request URL. You could inline all the loaders at minimal bundle cost and achieve complete parallelization between data fetching and component (or route module) chunk downloading.
const routes = [
{
path: 'projects',
loader: ({ request }) => fetchDataForUrl(request.url),
lazy: () => import('./projects-component'),
},
]