|
| 1 | +--- |
| 2 | +title: Faster Lazy Loading in React Router v7.5+ |
| 3 | +summary: React Router v7.5 now supports a faster, more granular approach to lazy loading route code in Data Mode |
| 4 | +date: 2025-04-17 |
| 5 | +image: /blog-images/headers/faster-lazy-loading.jpg |
| 6 | +ogImage: /blog-images/headers/faster-lazy-loading.jpg |
| 7 | +imageAlt: "A close-up of a abstract stylized colored tubes" |
| 8 | +imageDisableOverlay: true |
| 9 | +authors: |
| 10 | + - Mark Dalgleish |
| 11 | +--- |
| 12 | + |
| 13 | +<!-- Diagrams, exported as PNG @ 2x scale: https://excalidraw.com/#json=vlUxqYtuQD20IjsupqbRx,f_SWu-qs-A_0ea0JS2rBGw --> |
| 14 | + |
| 15 | +With the release of [React Router v7.5](https://reactrouter.com/changelog#v750), we’ve introduced a more granular way to lazy load route code in [Data Mode](https://reactrouter.com/start/modes). This new API is specifically designed to support the upcoming middleware API, but it also allows for some additional performance optimizations across the board. |
| 16 | + |
| 17 | +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. |
| 18 | + |
| 19 | +## Background |
| 20 | + |
| 21 | +In [React Router v6.4](https://reactrouter.com/changelog#v640), we introduced support for [lazy loading of routes](/blog/lazy-loading-routes) via an async `route.lazy()` function. Most commonly this was used to dynamically import a route module, for example: |
| 22 | + |
| 23 | +```tsx |
| 24 | +const routes = [ |
| 25 | + { |
| 26 | + path: "/", |
| 27 | + element: <Layout />, |
| 28 | + children: [ |
| 29 | + { |
| 30 | + index: true, |
| 31 | + element: <Home />, |
| 32 | + }, |
| 33 | + { |
| 34 | + path: "projects", |
| 35 | + lazy: () => import("./projects"), // 💤 Lazy load! |
| 36 | + children: [ |
| 37 | + { |
| 38 | + path: ":projectId", |
| 39 | + lazy: () => import("./project"), // 💤 Lazy load! |
| 40 | + }, |
| 41 | + ], |
| 42 | + }, |
| 43 | + ], |
| 44 | + }, |
| 45 | +]; |
| 46 | +``` |
| 47 | + |
| 48 | +Since each `route.lazy()` function is returning the result of a dynamic import, the imported modules need to provide route properties as exports: |
| 49 | + |
| 50 | +```tsx |
| 51 | +// projects.tsx |
| 52 | +export async function loader() { |
| 53 | + /* ... */ |
| 54 | +} |
| 55 | + |
| 56 | +export default function Component() { |
| 57 | + /* ... */ |
| 58 | +} |
| 59 | +``` |
| 60 | + |
| 61 | +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: |
| 62 | + |
| 63 | +<img alt="Waterfall diagram showing `Click /projects/123` with a row for the matching routes of `projects` and `:projectId`, with each matching route’s `route.lazy()` function being called in parallel followed by calling its loader, with each route being handled in parallel" src="/blog-images/posts/faster-lazy-loading/lazy-with-loaders.png" class="m-auto sm:w-4/5 lg:w-full border sm:p-3 rounded-md shadow" /> |
| 64 | + |
| 65 | +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. |
| 66 | + |
| 67 | +## How middleware challenged our approach |
| 68 | + |
| 69 | +As we were working on the upcoming middleware API, we realized that our approach to lazy loading had a critical limitation. |
| 70 | + |
| 71 | +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. |
| 72 | + |
| 73 | +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: |
| 74 | + |
| 75 | +<img alt="Waterfall diagram showing `Click /projects/123` with a row for the matching routes of `projects` and `:projectId`, with each matching route’s `route.lazy()` function being called in parallel, followed by the first route’s middleware being called once all `route.lazy()` functions have resolved, followed by all routes loaders being called in parallel" src="/blog-images/posts/faster-lazy-loading/lazy-with-loaders-and-middleware.png" class="m-auto sm:w-4/5 lg:w-full border sm:p-3 rounded-md shadow" /> |
| 76 | + |
| 77 | +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. |
| 78 | + |
| 79 | +<img alt="Waterfall diagram showing `Click /projects/123` with a row for the matching routes of `projects` and `:projectId`, with each matching route’s `route.lazy()` function being called in parallel, followed by all route loaders being called in parallel once all `route.lazy()` functions have resolved" src="/blog-images/posts/faster-lazy-loading/lazy-with-loaders-without-middleware.png" class="m-auto sm:w-4/5 lg:w-full border sm:p-3 rounded-md shadow" /> |
| 80 | + |
| 81 | +In the example above, all loaders were delayed unnecessarily, waiting on some potential lazy middleware that ultimately wasn’t there. |
| 82 | + |
| 83 | +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. |
| 84 | + |
| 85 | +## The new granular lazy loading API |
| 86 | + |
| 87 | +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. |
| 88 | + |
| 89 | +Instead of a single `route.lazy()` function, you can now define a `lazy` object with an async function for each property. |
| 90 | + |
| 91 | +```tsx |
| 92 | +// Before |
| 93 | +const route = { |
| 94 | + lazy: () => import("./projects"), |
| 95 | +}; |
| 96 | + |
| 97 | +// After |
| 98 | +const route = { |
| 99 | + lazy: { |
| 100 | + loader: async () => { |
| 101 | + return (await import("./projects")).loader; |
| 102 | + }, |
| 103 | + Component: async () => { |
| 104 | + return (await import("./projects")).Component; |
| 105 | + }, |
| 106 | + }, |
| 107 | +}; |
| 108 | +``` |
| 109 | + |
| 110 | +With this level of granularity, you’re also now able to split the code for lazy-loaded route properties into separate files: |
| 111 | + |
| 112 | +```ts |
| 113 | +const route = { |
| 114 | + lazy: { |
| 115 | + loader: async () => { |
| 116 | + return (await import("./projects/loader")).loader; |
| 117 | + }, |
| 118 | + Component: async () => { |
| 119 | + return (await import("./projects/component")).Component; |
| 120 | + }, |
| 121 | + }, |
| 122 | +}; |
| 123 | +``` |
| 124 | + |
| 125 | +This API gives us a couple of major benefits. |
| 126 | + |
| 127 | +First, we now know up front whether any of the matched routes contain lazy-loaded middleware. |
| 128 | + |
| 129 | +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. |
| 130 | + |
| 131 | +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. |
| 132 | + |
| 133 | +If we modify our earlier example to take advantage of the new granular `route.lazy` API, it looks like this: |
| 134 | + |
| 135 | +```tsx |
| 136 | +const routes = [ |
| 137 | + { |
| 138 | + path: "/", |
| 139 | + element: <Layout />, |
| 140 | + children: [ |
| 141 | + { |
| 142 | + index: true, |
| 143 | + element: <Home />, |
| 144 | + }, |
| 145 | + { |
| 146 | + path: "projects", |
| 147 | + lazy: { |
| 148 | + unstable_middleware: async () => { |
| 149 | + return (await import("./projects/middleware")).middleware; |
| 150 | + }, |
| 151 | + loader: async () => { |
| 152 | + return (await import("./projects/loader")).loader; |
| 153 | + }, |
| 154 | + Component: async () => { |
| 155 | + return (await import("./projects/component")).Component; |
| 156 | + }, |
| 157 | + }, |
| 158 | + children: [ |
| 159 | + { |
| 160 | + path: ":projectId", |
| 161 | + lazy: { |
| 162 | + loader: async () => { |
| 163 | + return (await import("./project/loader")).loader; |
| 164 | + }, |
| 165 | + Component: async () => { |
| 166 | + return (await import("./project/component")).Component; |
| 167 | + }, |
| 168 | + }, |
| 169 | + }, |
| 170 | + ], |
| 171 | + }, |
| 172 | + ], |
| 173 | + }, |
| 174 | +]; |
| 175 | +``` |
| 176 | + |
| 177 | +To visualize this on a timeline: |
| 178 | + |
| 179 | +<img alt="Waterfall diagram showing `Click /projects/123` with a row for the matching routes of `projects` and `:projectId`, with each matching route’s `route.lazy.middleware()`, `route.lazy.loader()`, and `route.lazy.Component()` functions being called in parallel. The lazy middleware is only present for the first route, and the middleware is called as soon as `route.lazy.middleware()` resolves. Once the middleware has finished being called, the route loaders are called in parallel." src="/blog-images/posts/faster-lazy-loading/granular-lazy-routes.png" class="m-auto sm:w-4/5 lg:w-full border sm:p-3 rounded-md shadow" /> |
| 180 | + |
| 181 | +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. |
| 182 | + |
| 183 | +## Further optimizations |
| 184 | + |
| 185 | +This API was initially introduced to support lazy loading of middleware. However, we quickly realized that it allowed for some additional performance improvements. |
| 186 | + |
| 187 | +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. |
| 188 | + |
| 189 | +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+. |
| 190 | + |
| 191 | +Both of these optimizations allow you to get similar runtime performance to Framework Mode’s [Split Route Modules](./split-route-modules) feature, but since you’re in Data Mode, you now have more control over the file structure. |
| 192 | + |
| 193 | +## Try it out |
| 194 | + |
| 195 | +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. |
| 196 | + |
| 197 | +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. |
| 198 | + |
| 199 | +We’re excited to see what you build with this new API ❤️ |
0 commit comments