Menu

React Suspense and lazy: Loading States and Code Splitting

Suspense shows a fallback while the components inside it are not ready, and React.lazy loads a component's code only when it first renders. Learn code splitting, nested boundaries, Suspense with use() in React 19, and handling errors.

This page includes runnable editors - edit, run, and see output instantly.

Suspense shows a fallback, such as a loading message, while the components inside it are not ready yet. React.lazy is the most common thing to wait for: it loads a component's code only when that component first renders, which keeps the initial download small.

Click Show chart: Loading chart... appears for a second, then the chart. Hide it and show it again, and it appears at once with no new log line, because lazy keeps the loaded module.

Code splitting with lazy

The editor holds everything in one file, so the example builds the slow module by hand: a promise that resolves to an object with a default export after one second. In a real app the component lives in its own file and you pass a dynamic import:

import { lazy, Suspense } from 'react';

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

export default function Dashboard() {
    return (
        <Suspense fallback={<p>Loading chart...</p>}>
            <Chart />
        </Suspense>
    );
}

import('./Chart.jsx') returns a promise for the module. Bundlers such as Vite and webpack see the dynamic import and put Chart.jsx and everything only it uses into a separate file, downloaded the first time <Chart /> renders. Rules to know:

  • The module needs a default export. lazy reads the default property of what the promise resolves to. For a named export, map it: lazy(() => import('./charts.js').then((m) => ({ default: m.LineChart }))).
  • Call lazy at the top level of a module. Inside a component it would create a new component type on every render, so React would unmount the old one, lose its state and load it again.
  • Split where the user waits anyway. Routes, modals, rarely opened panels and heavy widgets (editors, charts, maps) are good candidates. Splitting every small component adds requests and loading states for no gain.

Preloading before the click

A lazy component starts loading when it first renders, so the user always waits at least one download after clicking. If you can guess the click is coming, start the download earlier. Keep the import function in a variable and call it on hover or focus; the browser keeps the module, so when lazy calls the same import later, it resolves without a second download.

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

<button onMouseEnter={loadChart} onFocus={loadChart} onClick={() => setShow(true)}>
    Show chart
</button>

How Suspense decides what to show

When a component inside <Suspense> is not ready, it suspends: React stops rendering that part and shows the fallback of the closest Suspense above it. Everything inside that boundary is replaced by the fallback, not only the component that is waiting. When the thing it waited for is ready, React renders the content again and replaces the fallback.

That makes the position of the boundary a design decision. Put independent parts in their own boundaries so each can appear when it is ready:

First the whole page shows Loading page..., because Header belongs to the outer boundary. Once the header is ready, the article appears with Loading comments... under it, and the comments arrive last. Change 1500 to 3000 and the preview reloads with a longer wait for the comments only. Delete the inner <Suspense> (keep <Comments />) and the page waits for the comments before showing anything.

Suspense with use() in React 19

In React 19 a component can read a promise with use(promise). If the promise is still pending, the component suspends and the nearest Suspense shows its fallback; when it resolves, use returns the value. The use hook page covers it in full. The fake fetchUser below stands in for a real request.

Click through the users, then go back to User 1: it appears instantly and the Console logs no new fetch, because the promise is already in the cache.

Why the promise must be cached

The cache map is not an optimization here, it is required. A component that suspends does not keep anything from that attempt: when the promise resolves, React renders it again from the top. If Profile called fetchUser(id) directly, each attempt would create a new promise, start a new request and suspend on it again. The profile never appears, and the Console fills with fetching user lines. So the promise has to come from somewhere that outlives the render:

  • a cache keyed by the request, like the map above (data libraries such as TanStack Query and framework loaders do this for you);
  • a parent that creates the promise once, in an event handler or a Server Component, and passes it down as a prop.

Note that each new user still replaces the profile with the fallback. If you would rather keep the old user on screen until the next one is ready, wrap the update in a transition: startTransition(() => setId(n)). React does not hide content that is already visible for a transition (see useTransition).

Errors need an error boundary

Suspense handles waiting, not failing. If a lazy import fails (the user went offline, a new deploy removed the old chunk) or a promise passed to use rejects, React throws the error to the nearest error boundary. Without one, the whole tree below the root unmounts. Error boundaries are still class components (see error boundaries):

<ErrorBoundary fallback={<p>Could not load the chart.</p>}>
    <Suspense fallback={<p>Loading chart...</p>}>
        <Chart />
    </Suspense>
</ErrorBoundary>

What Suspense does not detect

Suspense only reacts to components that suspend: lazy components, use(promise), and data sources built for Suspense (framework loaders, Suspense-enabled libraries). A fetch inside useEffect that sets state when it completes does not suspend, so a Suspense boundary around it never shows its fallback. For that pattern you keep your own loading state, as shown on the fetching data page.

Frequently Asked Questions

What is React Suspense?

<Suspense fallback={...}> is a component that shows its fallback while any component inside it is waiting for something, such as lazily loaded code or data read with use. When everything inside is ready, React swaps the fallback for the content.

What does React.lazy do?

lazy(() => import('./Chart.jsx')) creates a component whose code is downloaded the first time it renders. Bundlers put that file in a separate chunk, so the initial page loads less JavaScript.

Does Suspense work for data fetching?

Yes, when the data source supports it. In React 19 a component can read a promise with use(promise) and suspend until it resolves. Frameworks such as Next.js also integrate Suspense with their data loading. A fetch inside useEffect does not trigger Suspense.

How do I handle errors with Suspense?

Suspense only handles waiting. If a lazy import or a promise fails, the error goes to the nearest error boundary, so wrap the Suspense boundary (or its parent) in one.

Where should I call lazy?

At the top level of a module, outside any component. Calling lazy inside a component creates a new component type on every render, which resets its state and reloads it.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED