The Remix team have released the new “Data Routers” in their 6.4
update that brings all of the great features of Remix over to React Router. With this update, Single Page Applications that are unable to migrate can benefit from all of the goodies 🎁 that Remix provides.
The best feature by far is parallel data fetching, removing the “Spinnageddon” network waterfall and improving page loads.
These parallel green bars look awesome! and your users will be delighted too:
- No Content Layout Shift
- Faster data loading.
- A single loading spinner (unavoidable without Remix because of Client Side Rendering, but that’s better than multiple ones).
It all sounds great, so what’s missing?
The new Data Router and routes that need data upfront have to define their loaders and components at the top level of the application and that puts the entire app into a single massive bundle 🤔
Hundreds of MBs of JavaScript when you land on the page is a no-no for performance so… how do we codesplit a Data Router application while keeping the benefits of parallel data fetching and no Content Layout Shift?
Option 1
Route Modules with Loaders and Components in the same file:
This option follows the Remix file conventions where every route exports both the Component
and the Loader
from the same file, then it uses a Route Module
file to lazy load and dynamic import these files to enable code-splitting.
export async function loader(args) {
let actualLoader = await import("./actualModule").loader;
return actualLoader(args);
}
export const Component = React.lazy(() => import("./actualModule").default);
import * as Tasks from "./tasks.route";
// ...
<Route
path="/tasks"
element={
<React.Suspense fallback="loading...">
<Tasks.Component />
</React.Suspense>
}
loader={Tasks.loader}
/>;
This resulting network waterfall:
Pros:
- This is a really clean pattern and can be encapsulated to build your own route configuration.
- Closer to Remix file conventions so it would be easier to migrate in the future.
Cons:
- The data loading and
fetch
are delayed until the Component bundle is loaded. - Content Layout Shift occurs because of the suspense boundary.
Option 2
Moving loaders into their own separate file
Separating the loader out of the Component file allows you to code split the Component and fetch it from the loader while the data is loading, eliminating the sequential download and making the most of parallelisation.
Move actual-loader
to a separate file.
export async function loader() {
// import the Component here but don't await it
import("./actualModule");
return await fetch('/api');
}
When React.lazy actually mounts and calls its ownimport('./actualModule')
, it should latch onto the existing download.
Disclaimer: Most modern bundlers should support the theory above, but it is not guaranteed.
Pros:
- The Component and Loader can be code-split into separate files.
- The Component’s javascript bundle and the data fetching start at the same time, this is particularly useful for heavy components that might take a while to load from the network.
Cons:
- This approach moves away from Remix file conventions so a small refactoring to move the loaders could be needed if migrating to Remix
- Content Layout Shift still occurs even though the component lazy import promise could have potentially resolved when the loader is finished, the suspense boundary still needs to unwrap the resolved value.
Removing Content Layout Shift
Both options have their benefits, however, both suffer from Content Layout Shift. Removing CLS is one of the main performance benefits of React Router and Remix but now that we have introduced code-splitting it makes CLS unavoidable… or does it?
Matt from the Remix team put together a really cool trick on how to remove CLS when code-splitting and the best part is that it works for both of the solutions mentioned above.
// Assume you want to do this in your routes, which are in the critical path JS bundle
<Route path="lazy" loader={lazyLoader} element={<Lazy />} />
// And assume you have two files containing your actual load and component:
// lazy-loader.ts -> exports the loader
// lazy-component.ts -> exports the component
// We'll render the component via React.lazy()
let LazyActual = React.lazy(() => import("./lazy-component"));
function Lazy() {
return (
<React.Suspense fallback={<p>Loading component...</p>}>
<LazyActual />
</React.Suspense>
);
}
// The loader is where things get interesting, we need to load the JS chunk
// containing our actual loader code - BUT we also want to get a head start
// on downloading the component chunk instead of waiting for React.lazy() to
// kick it off. Waterfalls are bad! This opens up the possibility of the
// component chunk finishing _before_ the loader chunk + execution. If that
// happens we don't want to see a small CLS flicker from Suspense since the
// component _is already downloaded_!
export async function lazyLoader(...args) {
let controller = new AbortController();
/*
* Kick off our component chunk load but don't await it
* This allows us to parallelise the component download with loader
* download and execution.
*
* Normal React.lazy()
*
* load loader.ts execute loader() load component.ts
* -----------------> -----------------> ----------------->
*
* Kicking off the component load _in_ your loader()
*
* load loader.ts execute loader()
* -----------------> ----------------->
* load component.ts
* ----------------->
*
* Kicking off the component load _alongside_ your loader.ts chunk load
*
* load loader.ts execute loader()
* -----------------> ----------------->
* load component.ts
* ----------------->
*/
import("./lazy-component").then(
(componentModule) => {
if (!controller.signal.aborted) {
// We loaded the component _before_ our loader finished, so we can
// blow away React.lazy and just use the component directly. This
// avoids the flicker we'd otherwise get since React.lazy would need
// to throw the already-resolved promise up to the Suspense boundary
// one time to get the resolved value
LazyActual = componentModule.default;
}
},
() => {}
);
try {
// Load our loader chunk
let { default: loader } = await import("./lazy-loader");
// Call the loader
return await loader(...args);
} finally {
// Abort the controller when our loader finishes. If we finish before the
// component chunk loads, this will ensure we still use React.lazy to
// render the component since it's not yet available. If the component
// chunk finishes first, it will have overwritten Lazy with the legit
// component so we'll never see the suspense fallback
controller.abort();
}
}
Thanks again to Matt from Remix for helping me to figure this one out! all the credit goes to him.