Kodokon kodokon.com

Suspense, lazy, and code splitting

Master the suspension mechanism, lazy component loading, and bundle splitting.

9 min · 3 questions

Open this lesson in Kodokon

Suspense relies on a little-known mechanism: a component that does not yet have its data or its code suspends its rendering. Under the hood, the current implementation throws a promise, which React intercepts just as it would intercept an error. React then walks up to the nearest <Suspense> boundary, displays its fallback, then replays the render of the subtree once the promise resolves. It is an implementation detail (never throw a promise yourself), but it explains all the observable behavior: the boundary acts as a declarative catch for async work. The historical use case is code splitting with React.lazy.

JSX
import { lazy, Suspense } from 'react';

const Chart = lazy(() => import('./Chart'));

function Dashboard() {
  return (
    <Suspense fallback={<Spinner />}>
      <Chart points={data} />
    </Suspense>
  );
}
Chart's code is only downloaded on its first render.

lazy expects a function that returns a promise resolving to a module with a default export. The splitting itself is the bundler's job: each dynamic import() becomes a split point and produces a separate chunk, loaded on demand. A common subtlety in real codebases: your components are often named exports. You then need to adapt the module on the fly.

JAVASCRIPT
const Chart = lazy(() =>
  import('./charts').then((module) => ({
    default: module.Chart,
  }))
);
Adapting a named export to the React.lazy contract.

The tricky part in production: during a navigation, content that is already displayed can suspend again (new route, new chunk) and be abruptly replaced by the fallback. Since React 18, an update wrapped in startTransition changes this behavior: React keeps the previous interface on screen while the new render suspends in the background, and only shows the fallback for content that has never been revealed. Boundaries also compose in depth: several nested <Suspense> boundaries let you reveal the interface zone by zone, from the global skeleton down to the details.

JSX
const [isPending, startTransition] = useTransition();

function openTab(nextTab) {
  startTransition(() => {
    setTab(nextTab);
  });
}
// The current tab stays visible while the new
// one loads, instead of dropping back to the fallback.
A transition avoids falling back to the fallback during navigation.

Knowledge check

Make sure you remember the key points of this lesson.

  1. What must the function passed to React.lazy return?
    • A React component directly
    • A promise resolving to a module with a default export
    • An object { component, loading }
    • A string containing the file path
  2. How do you prevent already-displayed content from being replaced by the fallback when a navigation suspends?
    • Wrap the state update in startTransition
    • Pass fallback={null} to the Suspense boundary
    • Use useEffect to delay the navigation
    • Remove the parent Suspense boundary
  3. Who actually performs the splitting of code into separate chunks?
    • React, during the commit phase
    • The browser, via the HTTP cache
    • The bundler, from each dynamic import()