Sergio Xalambrí

Lazy-load React components in Remix

Suppose we want to create a timeline component that lists different events. Each event type has a unique component associated, which will render a different UI.

But some users may not need to load the code for all the different event types, so we want to lazy-load the component for each event type. This way, users will only download the code for the components they see on the page.

To do this, we need to combine JS dynamic imports with React.lazy and React.Suspense.

Note: Ensure you're using React 18 since we need that version server-side to render lazy components. Older versions require you to wrap Suspense with a ClientOnly component.

Let's create our event components. We can have these two simple components for the test:

// app/components/a.tsx
export function A() {
  return <li>A</li>;
}
// app/components/b.tsx
export function B() {
  return <li>B</li>;
}

And now, let's create an Event component.

import { lazy } from "react";

function sleep(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

function random(min: number, max = min) {
  return Math.floor(Math.random() * (max - min + 1) + min);
}

let A = React.lazy(() =>
  sleep(random(100, 1000))
    .then(() => import("~/components/a"))
    .then((module) => ({ default: module.A }))
);

let B = React.lazy(() =>
  sleep(random(500, 2000))
    .then(() => import("~/components/b"))
    .then((module) => ({ default: module.B }))
);

export function Event({ type }: { type: "A" | "B" }) {
  if (type === "A") return <A />;
  if (type === "B") return <B />;
  return null;
}

Let's see more about what we do here.

The sleep and random functions will help us simulate a slow connection because the test components are too small. They'll load fast, so we can combine these functions with helping us see the lazy-loading in action.

Then we call React.lazy and pass a function first to call random. In component A we randomize between 100 to 1000, and in component B we randomize between 500 and 2000. We then use the randomized value to sleep for that amount of time.

Then we call import to load the component, and finally, we return the component. As you can see we need to do (module) => ({ default: module.A }) and (module) => ({ default: module.B }).

Adding this extra code is needed when you import a component using named exports instead of export default.

Finally, we export our Event component, which receives the event type ("A" | "B") and renders the associated component, or null if the type is another value (TypeScript should prevent that, but it could happen).

Now that we have the Event component and the lazy-load setup, we can start using it. To check how this work, we're going to create three routes.

  1. app/routes/index.tsx - Here, we will render a list of A and B events mixed.
  2. app/routes/only-a.tsx - Here, we will render a list of A events.
  3. app/routes/only-b.tsx - Here, we will render a list of B events.

The only B route

Let's start with the only-b.

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
import { Event } from "~/components/event";

export async function loader() {
  return json({ events: ["B", "B", "B", "B"] } as const);
}

export default function Index() {
  let { events } = useLoaderData<typeof loader>();

  return (
    <main>
      <h1>Only B</h1>
      <Suspense fallback={<p>Loading events</p>}>
        <ul>
          {events.map((event) => {
            return <Event key={event} type={event} />;
          })}
        </ul>
      </Suspense>
    </main>
  );
}

We have a loader that returns the list of events, which is only the event B in this case. Then in the UI, we render a title and a section with the list of events.

Here, we decided to put the Suspense boundary wrapping the whole section. React will show the boundary's fallback until all event components are loaded.

The only A route

We'll continue with the only A route.

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Event } from "~/components/event";

export async function loader() {
  return json({ events: ["A", "A", "A", "A"] as const });
}

export default function Index() {
  let { events } = useLoaderData<typeof loader>();

  return (
    <main>
      <h1>Only A</h1>
      <ul>
        {events.map((event) => {
          return <Event key={event} type={event} />;
        })}
      </ul>
    </main>
  );
}

Almost everything here is the same. With the difference, we only send the event A and don't use the Suspense boundary. So, what will happen here? React will not send the initial HTML response until all the components are loaded as if we didn't use lazy loading!

The benefit here is that the user will only receive the code for component A, so it benefits from the code splitting without having to render a fallback component while it loads.

React will wait until all the required components load on client-side navigation to show the UI.

However, there's a problem here. We can't start downloading the code of the lazy component until we first have the data, so this creates a waterfall of requests.

  1. Load data and route code
  2. Load lazy components code
  3. Render everything

The mixed route

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
import { Event } from "~/components/event";

export async function loader() {
  let events: Array<"A" | "B"> = Array.from({ length: 10 }, () =>
    Math.random() > 0.5 ? "A" : "B"
  );

  return json({ events });
}

export default function Index() {
  let { events } = useLoaderData<typeof loader>();

  return (
    <main>
      <h1>Mixed A and B</h1>

      <ul>
        {events.map((event) => {
          return (
            <Suspense fallback={<li>Loading event {event}...</li>} key={event}>
              <Event type={event} />
            </Suspense>
          );
        })}
      </ul>
    </main>
  );
}

Then we have the last one. Here we generate a random list of events A and B mixed in the loader.

The UI renders the list of events, but we're wrapping each event in a Suspense boundary with a fallback.

We'll render each event as soon as it's loaded, and we'll render the fallback for every individual component in the meantime.

Because most of the time, the component A is going to load first, we will see a list of A events mixed with the fallbacks for the B events. In some cases, though, the B component may load faster.

We could also conditionally apply the Suspense boundary.

{
  events.map((event) => {
    if (event === "A") return <Event type={event} key={event} />;
    return (
      <Suspense fallback={<li>Loading event {event}...</li>} key={event}>
        <Event type={event} />
      </Suspense>
    );
  });
}

By doing this, event A will block the render of the route, but event B will suspend until it's loaded.

We could change it so the A event suspends and B does not, and now if A is slower, it will show the fallback, but most of the time, B is slower (because of our randomized sleep) so the whole list of events will render immediately.

Suspending entire routes

There's another thing we could do. We can suspend the entire route until all the components are loaded.

<Suspense fallback={<p>Waiting for route</p>}>
  <Outlet />
</Suspense>

By wrapping the Outlet component in a Suspense boundary, any lazy-loaded component inside a nested route that doesn't have a specific Suspense boundary will cause the whole route to suspend. We can do this on our root.tsx file, for example, and now if we navigate to the only A route because we don't use the Suspense component inside, it will cause the whole route to suspend, but React will send any UI in the root.tsx file to the browser immediately.

Older React versions

If you're on React 17, while it supports React.lazy and React.Suspense, it doesn't support using them on server-side rendering. In that case, we'll have to ensure the React.Suspense component will render client-side. We can do this using the ClientOnly component from the Remix Utils package.

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
import { ClientOnly } from "remix-utils";
import { Event } from "~/components/event";

export async function loader() {
  let events: Array<"A" | "B"> = Array.from({ length: 10 }, () =>
    Math.random() > 0.5 ? "A" : "B"
  );

  return json({ events });
}

export default function Index() {
  let { events } = useLoaderData<typeof loader>();

  return (
    <main>
      <h1>Mixed A and B</h1>

      <ul>
        {events.map((event) => {
          return (
            <ClientOnly
              fallback={<li>Loading event {event}...</li>}
              key={event}
            >
              <Suspense fallback={<li>Loading event {event}...</li>}>
                <Event type={event} />
              </Suspense>
            </ClientOnly>
          );
        })}
      </ul>
    </main>
  );
}

As you can see, we re-used the fallback from the Suspense boundary, so the user may not even know the difference visually. Once the app hydrates the client side, it will start the lazy load of the components and suspend until the load completes.