banner
AgedCoffee

AgedCoffee

LazyLoading in ReactRouter6-4

Original article: Lazy Loading Routes in React Router 6.4+

Lazy Loading Routes in React Router 6.4+#

React Router 6.4 introduces the concept of "data routers" with a focus on separating data fetching from rendering to eliminate render + fetch chains and the accompanying loading spinners.

These chains are often referred to as "waterfalls," but we're rethinking that term because most people hear "waterfall" and think of Niagara Falls, where all the water falls into one beautiful big waterfall. But what if "all at once" is a good way to fetch data? Why hate on waterfalls? Maybe we should embrace them?

In reality, the "waterfalls" we want to avoid look more like the header image above and are more like stairs. Water flows down a bit, then stops, then down a bit more, then stops, and so on. Now imagine a loading spinner in each step of that staircase. That's not the kind of UI we want to give our users! So, in this article (and hopefully beyond), we use the term "chains" to represent the inherent order of fetching, with each fetch blocked by the one before it.

Render + Fetch Chains#

If you haven't read the article "Remixing React Router" or Ryan's "When to Fetch" talk from last year's Reactathon, I recommend checking those out before diving into this one. They cover a lot of the background behind the data router concept we introduced.

In short, when your router doesn't know your data needs, you end up with link requests and subsequent data needs "discovered" as you render child components.

Coupling data fetching with components leads to render + fetch chains

But introducing data routers allows you to fetch data in parallel and render everything at once:

Parallelized route fetching eliminates slow render + fetch chains

To achieve this, the data router extracts your route definitions from the render cycle so that our router can identify nested data needs ahead of time.

// app.jsx
import Layout, { getUser } from `./layout`
import Home from `./home`
import Projects, { getProjects } from `./projects`
import Project, { getProject } from `./project`

const routes = [
  {
    path: '/',
    loader: () => getUser(),
    element: <Layout />,
    children: [
      {
        index: true,
        element: <Home />,
      },
      {
        path: 'projects',
        loader: () => getProjects(),
        element: <Projects />,
        children: [
          {
            path: ':projectId',
            loader: ({ params }) => getProject(params.projectId),
            element: <Project />,
          },
        ],
      },
    ],
  },
]

But there's a downside to this as well. So far, we've talked about optimizing data fetching, but we also need to consider optimizing the fetching of JS bundles! With the above route definition, while we can fetch all the data in parallel, we're blocking the start of data fetching because we have to download the JavaScript bundle that contains all the loaders and components.

Consider a user entering your site at the / route:

A single JS bundle blocking data fetching

Can React.lazy save us?#

React.lazy provides a great primitive for chunking the component tree, but it suffers from the same issue of being tightly coupled with the fetching and rendering that data routers are trying to eliminate 😕. This is because when you use React.lazy(), you create an async chunk for your component, but React doesn't start fetching that chunk until the lazy component is rendered.

// app.jsx
const LazyComponent = React.lazy(() => import('./component'))

function App() {
  return (
    <React.Suspense fallback={<p>Loading lazy chunk...</p>}>
      <LazyComponent />
    </React.Suspense>
  )
}

React.lazy() calls create a similar render + fetch chain.

So while we can leverage React.lazy() in data routers, we end up introducing a chain for fetching the components. Ruben Casas wrote a great article that covers some ways to do code splitting with React.lazy() in data routers. But as you can see from that article, manual code splitting is still a bit verbose and cumbersome. Because the DX wasn't great, we received a suggestion from @rossipedia (and an initial POC implementation). This suggestion nicely summarized the challenges we're facing and got us thinking about how to introduce first-class code splitting support in the RouterProvider. Huge props to these two (and other awesome community members) for actively participating in the evolution of React Router 🙌.

Introducing Route.lazy#

If we want lazy loading to play nicely with data routers, we need to be able to lazily import outside of the render cycle. Just as we extracted data fetching from the render cycle, we also want to extract route fetching.

If you step back and look at a route definition, it can be broken down into three parts:

  • Path matching fields, like path, index, and children
  • Data loading/submission fields, like loaders and actions
  • Rendering fields, like elements and error elements

What the data router really needs on the critical path is the path matching fields because it needs to be able to identify all the routes that match a given URL. Once matched, we already have async navigation in progress, so there's no reason we can't fetch route information during that navigation. Then, we don't need the rendering aspect of it until after the data fetching is complete because we don't want to render the target route until the data fetching is done. Yes, this might introduce the concept of a "chain" (load the route, then load the data), but it's an optional lever we can pull to solve the tradeoff between initial load speed and subsequent navigation speed.

Here's an example using the above route structure and the new lazy() method (available in React Router v6.9.0) within a route definition:

// app.jsx
import Layout, { getUser } from `./layout`;
import Home from `./home`;

const routes = [{
  path: '/',
  loader: () => getUser(),
  element: <Layout />,
  children: [{
    index: true,
    element: <Home />,
  }, {
    path: 'projects',
    lazy: () => import("./projects"), // 💤 Lazy load!
    children: [{
      path: ':projectId',
      lazy: () => import("./project"), // 💤 Lazy load!
    }],
  }],
}]

// projects.jsx
export function loader = () => { ... }; // formerly named getProjects

export function Component() { ... } // formerly named Projects

// project.jsx
export function loader = () => { ... }; // formerly named getProject

export function Component() { ... } // formerly named Project

Did you ask about exporting functional components? The properties exported from this lazy module are added verbatim to the route definition. Since exporting an element is weird, we added support for defining components on the route object instead of elements (but don't worry, elements can still be used!).

In this case, we chose to leave the layout and home routes in the main bundle because those are the entry points our users hit most frequently. However, we've moved the project import and route import into their own dynamic imports that won't be loaded until those routes are navigated to.

On initial load, the resulting network graph looks something like this:

lazy() method allows us to trim the critical path bundle

Now our critical path bundle only includes the routes we consider most critical for entering the site. Then, when a user clicks a link to /projects/123, we fetch those routes in parallel using the lazy() method and execute the loader methods they return:

We lazily load routes in parallel as we navigate

This gives us a bit of the best of both worlds because we're able to bundle the critical path with the associated home routes. Then, on navigation, we can match the path and fetch the necessary new route definitions.

Advanced Usage and Optimizations#

Some keen readers might have some 🕷️ Spider-Man-like intuition that there's something hidden here. Is this the optimal network graph? Turns out, it's not! But considering how little code we wrote to get it, it's pretty darn good 😉.

In the example above, our route module includes both our loader and component, which means we need to download both before we can start rendering. In reality, in a React Router SPA, your loaders are usually very small and access external APIs where most of the business logic lives. On the other hand, components define the entire user interface, including all the user interactions associated with it—they can get quite large.

Blocking the loader (which might be making fetch() calls to an API) from downloading a large component tree with JS seems silly. What if we could turn this 👆 into this 👇?

The good news is, you can achieve this with minimal code changes! If you statically define a loader/action on a route, it will execute in parallel with anything returned from lazy(). This allows us to decouple the loader data fetching from the component chunk downloading by splitting the loader and component into separate files:

const routes = [
  {
    path: 'projects',
    async loader({ request, params }) {
      let { loader } = await import('./projects-loader')
      return loader({ request, params })
    },
    lazy: () => import('./projects-component'),
  },
]

Anything statically defined on the route will always take precedence over anything returned from lazy. So while you shouldn't define a static loader and return a loader from lazy at the same time, if you do, the lazy version will be ignored and you'll get a console warning.

This concept of statically defined loaders also opens up some interesting possibilities for directly inline code. For example, maybe you have a separate API endpoint that knows how to get data for a given route based on the request URL. You can inline all the loaders at minimal bundle cost and achieve complete parallelization between data fetching and component (or route module) chunk downloading:

const routes = [
  {
    path: 'projects',
    loader: ({ request }) => fetchDataForUrl(request.url),
    lazy: () => import('./projects-component'),
  },
]

Look, Mom, no loader chunk!

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.