React Router 6.4 Code-Splitting


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.

Parallel bars in network waterfall

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";

// ...
     <React.Suspense fallback="loading...">
       <Tasks.Component />

This resulting network waterfall:

Network waterfall with two bars


  • 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.


  • 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.

Network waterfall with two bars

Move actual-loader to a separate file.

export async function loader() {
  // import the Component here but don't await it
  return await fetch('/api');

When React.lazy actually mounts and calls its own
import('./actualModule'), it should latch onto the existing download.

Disclaimer: Most modern bundlers should support the theory above, but it is not guaranteed.


  • 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.


  • 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 />

// 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
   *   ----------------->
    (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

Source Code

Thanks again to Matt from Remix for helping me to figure this one out! all the credit goes to him.

Recent Posts

React Router 6 Deferred Fetch

React Router 6 introduced the “deferred” API that allows you to “await” for critical data and “defer” optional data when calling your loaders.

What are Micro-Frontends? Really…

Time and time and again I find a lot of confusion about what Micro-Frontends really are and what they are meant to solve. Here is my take on what Micro-Frontends are by discovering what they are not.

“The Risks of Micro-Frontends”

Here are some common Micro-Frontends risks and disadvantages you should be aware of: