With the release of React Router v7.5, we’ve introduced a more granular way to lazy load route code in Data Mode. This new API is specifically designed to support the upcoming middleware API, but it also allows for some additional performance optimizations across the board.
This post will look at React Router’s pre-existing approach to lazy loading routes, explain its limitations and the challenges it presented for middleware, and show how our new approach allows for much better lazy loading performance.
In React Router v6.4, we introduced support for lazy loading of routes via an async route.lazy()
function. Most commonly this was used to dynamically import a route module, for example:
const routes = [
{
path: "/",
element: <Layout />,
children: [
{
index: true,
element: <Home />,
},
{
path: "projects",
lazy: () => import("./projects"), // 💤 Lazy load!
children: [
{
path: ":projectId",
lazy: () => import("./project"), // 💤 Lazy load!
},
],
},
],
},
];
Since each route.lazy()
function is returning the result of a dynamic import, the imported modules need to provide route properties as exports:
// projects.tsx
export async function loader() {
/* ... */
}
export default function Component() {
/* ... */
}
When clicking a link to a new route, each matching route’s lazy function would be invoked before calling loaders. Visualized in a timeline, it looks like this:
With this route.lazy()
API, we were able to provide a nice, simple way to split route code out of the main bundle and only load it when needed.
As we were working on the upcoming middleware API, we realized that our approach to lazy loading had a critical limitation.
Up to this point, lazy routes could be loaded in parallel with their loaders/actions used as soon as they’re available. However, middleware is completely different. Middleware doesn’t just affect the route it’s defined on — it affects all of its descendant routes too. This means that we need to know whether any of the matched routes contain middleware before we can call any loaders or actions.
If we were to continue using the existing route.lazy()
API with middleware, we wouldn’t be able to start executing middleware until every single lazy
function for all matching routes had been resolved:
To make matters worse, you’d have to pay this performance penalty even if you weren’t using any middleware at all. It’s entirely possible to wait for all route.lazy()
functions to resolve only to discover that none of the matching routes even have middleware.
In the example above, all loaders were delayed unnecessarily, waiting on some potential lazy middleware that ultimately wasn’t there.
This problem meant that the existing route.lazy()
API couldn’t support middleware without seriously degrading performance for all consumers, whether or not they’re using middleware. We needed to find a better approach.
To address this, React Router v7.5 introduces a more granular, object-based route.lazy
API that allows you to lazy load individual route properties rather than having to load them all at once.
Instead of a single route.lazy()
function, you can now define a lazy
object with an async function for each property.
// Before
const route = {
lazy: () => import("./projects"),
};
// After
const route = {
lazy: {
loader: async () => {
return (await import("./projects")).loader;
},
Component: async () => {
return (await import("./projects")).Component;
},
},
};
With this level of granularity, you’re also now able to split the code for lazy-loaded route properties into separate files:
const route = {
lazy: {
loader: async () => {
return (await import("./projects/loader")).loader;
},
Component: async () => {
return (await import("./projects/component")).Component;
},
},
};
This API gives us a couple of major benefits.
First, we now know up front whether any of the matched routes contain lazy-loaded middleware.
Note that, for this to be the case, we’ve also had to limit the existing route.lazy()
API so that it can’t be used to lazy load middleware. If you want to lazy load middleware, you must use the new granular lazy loading API.
Additionally, since you can now split the code for lazy-loaded route properties into separate files, we can ensure that we’re only waiting on the minimum amount of code needed for each step of a navigation. For middleware, this means that we’re only waiting on route.lazy.unstable_middleware()
to resolve before executing it.
If we modify our earlier example to take advantage of the new granular route.lazy
API, it looks like this:
const routes = [
{
path: "/",
element: <Layout />,
children: [
{
index: true,
element: <Home />,
},
{
path: "projects",
lazy: {
unstable_middleware: async () => {
return (await import("./projects/middleware")).middleware;
},
loader: async () => {
return (await import("./projects/loader")).loader;
},
Component: async () => {
return (await import("./projects/component")).Component;
},
},
children: [
{
path: ":projectId",
lazy: {
loader: async () => {
return (await import("./project/loader")).loader;
},
Component: async () => {
return (await import("./project/component")).Component;
},
},
},
],
},
],
},
];
To visualize this on a timeline:
Now we’re only waiting on a single route.lazy.unstable_middleware()
function to resolve during the middleware phase, executing it as soon as it’s available. Meanwhile, we’re also downloading the lazy loader
and Component
route properties in parallel.
This API was initially introduced to support lazy loading of middleware. However, we quickly realized that it allowed for some additional performance improvements.
When executing loaders/actions, we only need to wait for route.lazy.loader()
or route.lazy.action()
to resolve before calling them, whereas previously we had to wait for all lazy-loaded properties to load.
We also skip route.lazy.HydrateFallback()
/ hydrateFallbackElement()
when navigating client-side. If you author the code for these properties in separate files, you can avoid downloading the HydrateFallback
entirely since it’s only used for the initial page load. Note that this specific optimization is available in React Router v7.5.1+.
Both of these optimizations allow you to get similar runtime performance to Framework Mode’s Split Route Modules feature, but since you’re in Data Mode, you now have more control over the file structure.
If you’re a Framework Mode consumer on React Router v7.5+, your app is already using the new granular lazy loading API under the hood.
If you’re a Data Mode consumer using the existing route.lazy()
API, you might want to consider updating to the new granular lazy loading API and splitting your loader/action and Component
/ HydrateFallback
code out into separate files. How you choose to split your code is up to you, and this new API provides the flexibility needed to get the best loading performance for your app.
We’re excited to see what you build with this new API ❤️