Skip to content

Commit 0731b4c

Browse files
Add Split Route Modules blog post (#320)
1 parent 27652e7 commit 0731b4c

File tree

6 files changed

+208
-0
lines changed

6 files changed

+208
-0
lines changed

data/posts/split-route-modules.md

+208
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
---
2+
title: Split Route Modules
3+
summary: React Router v7.2’s framework mode now supports automatic code splitting of route modules
4+
date: 2025-03-06
5+
image: /blog-images/headers/split-route-modules.jpg
6+
ogImage: /blog-images/headers/split-route-modules.jpg
7+
imageAlt: "A close-up of a stylized atom"
8+
imageDisableOverlay: true
9+
authors:
10+
- Mark Dalgleish
11+
---
12+
13+
<!-- Diagrams: https://excalidraw.com/#json=V90fQLt2mHxQnRuXn-5hS,pBKDQqcOAiKfXZf4bTRh1g -->
14+
15+
With the release of [React Router v7.2.0](https://github.com/remix-run/react-router/releases/tag/react-router%407.2.0), we’ve introduced a new opt-in framework feature called Split Route Modules. In this post, we’ll explore the performance problem that Split Route Modules solves, how it works, and how to use it today.
16+
17+
Please note that this feature is currently [unstable](https://reactrouter.com/community/api-development-strategy#unstable-flags), enabled by the `future.unstable_splitRouteModules` flag. We’d love any interested users to play with it locally and provide feedback, but we do not recommend using it in production yet. If you do choose to adopt this flag in production, please ensure you do sufficient testing against your production build to ensure that the optimization is working as expected.
18+
19+
## Route Modules
20+
21+
One of React Router’s defining features in framework mode is the [Route&nbsp;Module&nbsp;API](https://reactrouter.com/start/framework/route-module) which lets you define everything a route needs in a single file. While convenient, this API can sometimes come with a performance tradeoff.
22+
23+
Take, for example, the following route module:
24+
25+
```ts
26+
import { MassiveComponent } from "~/components";
27+
28+
export async function clientLoader() {
29+
return await fetch("https://example.com/api").then(
30+
(response) => response.json()
31+
);
32+
}
33+
34+
export default function Component({ loaderData }) {
35+
return <MassiveComponent data={loaderData} />;
36+
}
37+
```
38+
39+
Here we have a small `clientLoader` that makes a basic fetch call to an external API. The route component, on the other hand, is much larger.
40+
41+
Unfortunately, because these exports are both contained within the same module, the client loader has to wait for the rest of the route module to download before it can start running. If the client loader needs to perform an async operation, like hitting an external API, this delay can be felt by the user.
42+
43+
To visualize this as a timeline:
44+
45+
<img alt="Waterfall diagram showing `Click /route` triggering `Get route module` (split into `clientLoader` and `Component` segments) followed by `Run clientLoader` before `Render content` occurs" src="/blog-images/posts/split-route-modules/get-route-module.png" class="m-auto sm:w-4/5 border rounded-md shadow" />
46+
47+
Note that, while the route module is a single request from the browser, in this diagram we’re showing how it’s made up of multiple logical units — in this case, `clientLoader` and `Component`.
48+
49+
The `clientLoader` is delayed from calling the external API since it has to wait for the hypothetical `MassiveComponent` to download. Since we can’t render the component without the data from the client loader, the user has to wait for this network waterfall to complete before the page is rendered.
50+
51+
Ideally we’d like to be able to download the `clientLoader` export independently and run it as soon as it’s available:
52+
53+
<img alt="Waterfall diagram showing `Click /route` triggering a parallel `Get clientLoader` and `Get Component` followed by `Run clientLoader` before `Render content` occurs, now earlier than before" src="/blog-images/posts/split-route-modules/get-route-module-split.png" class="m-auto sm:w-4/5 border rounded-md shadow" />
54+
55+
At a framework level, the easiest way for us to solve this would be to force you to author your route in multiple files (`route/clientLoader.ts`, `route/component.tsx`, etc.) — but we really didn’t want to give up on the convenience of the Route Module API. The question is, how do we achieve this?
56+
57+
## Splitting the Route Module
58+
59+
What if the React Router Vite plugin could automatically split these route module exports into multiple smaller modules during the production build?
60+
61+
With the `future.unstable_splitRouteModules` flag enabled, this is exactly what happens.
62+
63+
Using our previous example, our singular route module would end up being split into two separate [virtual modules](https://vite.dev/guide/api-plugin#virtual-modules-convention) — one for the client loader and one for the component.
64+
65+
```ts
66+
// route.tsx?route-chunk=clientLoader
67+
export async function clientLoader() {
68+
return await fetch("https://example.com/api").then((response) =>
69+
response.json(),
70+
);
71+
}
72+
```
73+
74+
```ts
75+
// route.tsx?route-chunk=main
76+
import { MassiveComponent } from "~/components";
77+
78+
export default function Component({ loaderData }) {
79+
return <MassiveComponent data={loaderData} />;
80+
}
81+
```
82+
83+
Since these exports have now been split into separate modules, the React Router Vite plugin can ensure that they are downloaded independently.
84+
85+
This optimization is even more pronounced when using additional parts of the Route Module API. For example, when using `clientLoader`, `clientAction` and `HydrateFallback`, the timeline for a single route module during a client-side navigation might look like this:
86+
87+
<img alt="Waterfall diagram showing `Click /route` triggering `Get route module` (split into `clientLoader`, `clientAction`, `Component` and `HydrateFallback` segments) followed by `Run clientLoader` before `Render content` occurs" src="/blog-images/posts/split-route-modules/get-big-route-module.png" class="m-auto sm:w-4/5 border rounded-md shadow" />
88+
89+
This would instead be optimized to the following:
90+
91+
<img alt="Waterfall diagram showing `Click /route` triggering a parallel `Get clientLoader`, `Get clientAction` and `Get Component` (with `HydrateFallback` being skipped) followed by `Run clientLoader` before `Render content` occurs, now earlier than before" src="/blog-images/posts/split-route-modules/get-big-route-module-split.png" class="m-auto sm:w-4/5 border rounded-md shadow" />
92+
93+
This looks much better! As before, the client loader doesn’t need to wait for the component to download, and now it doesn’t need to wait for the `clientAction` or `HydrateFallback` exports to download either. In fact, it doesn’t even need to download the `HydrateFallback` export at all during client navigations since it’s only ever used on the initial page load.
94+
95+
You might be surprised to see `clientAction` in the timeline above, even though we’re simply navigating to a new route. Technically, we could have skipped downloading it altogether at this point since it’s not needed yet. However, we’ve opted to download the `clientAction` as soon as the route module is needed in order to improve the performance of any subsequent form submissions.
96+
97+
As you can see, this approach allows us to manage both the downloading and execution of each individual route export in isolation. We can download everything as soon as the route module is needed, but only ever wait for the exports that are needed for the current user interaction.
98+
99+
## Limitations
100+
101+
It’s worth being aware that route modules can be written in a way that doesn’t support code splitting.
102+
103+
For example, take the following (admittedly contrived) route module:
104+
105+
```tsx
106+
// routes/example.tsx
107+
import { MassiveComponent } from "~/components";
108+
109+
const shared = () => console.log("hello");
110+
111+
export async function clientLoader() {
112+
shared();
113+
return await fetch("https://example.com/api").then((response) =>
114+
response.json(),
115+
);
116+
}
117+
118+
export default function Component({ loaderData }) {
119+
shared();
120+
return <MassiveComponent data={loaderData} />;
121+
}
122+
```
123+
124+
Since the `shared` function that’s used in the `clientLoader` is also used in the `default` component export, the React Router Vite plugin will not be able to split the client loader into its own module.
125+
126+
**If a route module cannot be split, your application will still continue to work as expected.** The only difference is that the client loader can’t be downloaded independently of the component, giving you the same performance tradeoffs that route modules have always had.
127+
128+
That said, you can avoid this de-optimization by ensuring that any code shared between exports is extracted into a separate file. In our example, that might mean creating a `shared.ts` file:
129+
130+
```tsx
131+
// routes/example/shared.ts
132+
export const shared = () => console.log("hello");
133+
```
134+
135+
You can then import this shared code in your route module:
136+
137+
```tsx
138+
// routes/example/route.tsx
139+
import { MassiveComponent } from "~/components";
140+
import { shared } from "./shared";
141+
142+
export async function clientLoader() {
143+
shared();
144+
return await fetch("https://example.com/api").then((response) =>
145+
response.json(),
146+
);
147+
}
148+
149+
export default function Component({ loaderData }) {
150+
shared();
151+
return <MassiveComponent data={loaderData} />;
152+
}
153+
```
154+
155+
Since the shared code is now in its own module, the route module can be split into two separate virtual modules that import the `shared` function:
156+
157+
```tsx
158+
// routes/example/route.tsx?route-chunk=clientLoader
159+
import { shared } from "./shared";
160+
161+
export async function clientLoader() {
162+
shared();
163+
return await fetch("https://example.com/api").then((response) =>
164+
response.json(),
165+
);
166+
}
167+
```
168+
169+
```tsx
170+
// routes/example/route.tsx?route-chunk=main
171+
import { MassiveComponent } from "~/components";
172+
import { shared } from "./shared";
173+
174+
export default function Component({ loaderData }) {
175+
shared();
176+
return <MassiveComponent data={loaderData} />;
177+
}
178+
```
179+
180+
## Try it out
181+
182+
This feature will be enabled by default in a future release, but you can try it out today by setting the `future.unstable_splitRouteModules` flag in your React Router config:
183+
184+
```ts
185+
// react-router.config.ts
186+
import type { Config } from "@react-router/dev/config";
187+
188+
export default {
189+
future: {
190+
unstable_splitRouteModules: true,
191+
},
192+
} satisfies Config;
193+
```
194+
195+
If your project is especially performance sensitive, you can set `future.unstable_splitRouteModules` to `"enforce"`. This will break the build if any route module cannot be split.
196+
197+
```ts
198+
// react-router.config.ts
199+
import type { Config } from "@react-router/dev/config";
200+
201+
export default {
202+
future: {
203+
unstable_splitRouteModules: "enforce",
204+
},
205+
} satisfies Config;
206+
```
207+
208+
As always, we’d love to hear your feedback. If you run into any problems or have suggestions for improvements, please [file an issue](https://github.com/remix-run/react-router/issues) or [start a discussion](https://github.com/remix-run/react-router/discussions).
Loading
Loading
Loading
Loading
Loading

0 commit comments

Comments
 (0)