Skip to main content

The Suspense Mental Model

What you'll learn

  • What <Suspense> actually declares (it's simpler than its reputation)
  • The try/catch analogy: components throw "not ready yet", boundaries catch it
  • What "ready" means: code (lazy bundles) and data (promises)
  • What Suspense is not, and the myths to unlearn
  • How Suspense replaces the isLoading boolean pattern

Ask ten React developers what Suspense is and you'll get ten nervous answers. "It's for data fetching?" "It's that spinner thing?" "It's experimental?" The truth is much smaller and much more elegant: Suspense is one declarative sentence. Once you hear the sentence, everything else in this chapter, and the next four, is just unpacking it.

The one-sentence idea

import { Suspense } from 'react';
import { Spinner } from './Spinner';
import { Profile } from './Profile';

export default function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Profile />
    </Suspense>
  );
}

That JSX says exactly one thing:

"If anything inside this box isn't ready yet, show this fallback instead."

That's the entire API. fallback is what to show while waiting. The children are what to show when they're ready. Suspense is the boundary between those two states.

Jargon: "Suspense boundary". The region of the tree wrapped in <Suspense>. If any component inside the boundary reports "I'm not ready", the boundary swaps to its fallback. Components outside the boundary are completely unaffected.

What happens:

  1. React starts rendering Profile.
  2. Something inside Profile says "not ready yet" (you'll learn the exact mechanism next chapter).
  3. React walks up the tree to the nearest <Suspense>, our boundary.
  4. React shows <Spinner /> in place of the boundary's children.
  5. When the thing becomes ready, React re-renders the children and swaps in the real Profile.

Notice what you did not write: no if (loading) check, no state, no effect. The "pending" concern lives in one place, the boundary, instead of being smeared across every component.

The analogy you already know: try/catch

You already understand this control flow from error handling:

try {
const data = JSON.parse(maybeBroken); // might throw
render(data);
} catch (error) {
showError(error);
}

JSON.parse doesn't return an error, it throws one, and the nearest enclosing catch handles it. The throwing code doesn't know who catches; the catching code doesn't know what threw. They're decoupled by the throw.

Suspense is the same pattern, rotated 90 degrees:

Components throw their "not ready yet" state. Boundaries catch it.

A component that needs data it doesn't have yet doesn't return a spinner, doesn't set a flag, it throws its not-readiness (a promise, as you'll see next chapter) and lets the nearest boundary above decide what to show. Just like catch, the boundary doesn't care which descendant threw or why; it just shows the fallback until things are ready.

This decoupling is the whole point. Your Profile component can say "I need a user" without knowing anything about spinners, and your boundary can say "show a spinner" without knowing anything about users.

What does "ready" mean?

Two kinds of things can be un-ready, and Suspense handles both identically:

1. Code that hasn't arrived. With React.lazy, a component's code lives in a separate bundle that must be downloaded:

import { Suspense, lazy } from 'react';
import { Spinner } from './Spinner';

// The code for AdminPanel loads on demand, not upfront
const AdminPanel = lazy(() => import('./AdminPanel'));

export default function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <AdminPanel />
    </Suspense>
  );
}

What happens: the first time AdminPanel renders, its code isn't here yet, so it reports "not ready". The boundary shows the spinner. When the download finishes, the panel appears. The code itself was the thing being awaited.

2. Data that hasn't arrived. A component can read a promise during render with the use() hook, or through a Suspense-aware data library (many routers and data frameworks are):

import { Suspense, use } from 'react';
import { Spinner } from './Spinner';
import { fetchUser } from './api';

function Greeting({ userPromise }) {
  const user = use(userPromise); // suspends until this resolves
  return <h1>Hello, {user.name}!</h1>;
}

export default function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <Greeting userPromise={fetchUser(1)} />
    </Suspense>
  );
}

What happens: use(userPromise) either returns the user right now or reports "not ready" to the boundary. Same machinery as the lazy bundle, a promise is a promise, whether it represents "downloading code" or "fetching a user".

What Suspense is NOT

Half the confusion around Suspense comes from myths. Let's kill them now:

  • Suspense is not a data fetching library. It never fetches anything. It doesn't know what a network is. It only coordinates the display of things that are already fetching. The fetching is done by your own code, a router, or a data library.
  • Suspense is not a loading-boolean manager. You don't tell it "loading is true now". Components declare their needs during render; the boundary notices unmet needs. It's push, not pull.
  • Suspense is not tied to any fetching approach. fetch, axios, GraphQL, a router loader, a WebSocket, anything that produces a promise can participate. Suspense is a display primitive, not a network primitive.

Before and after: the user card

Here's the pattern every React developer has written a hundred times:

import { useState, useEffect } from 'react';
import { Spinner } from './Spinner';
import { fetchUser } from './api';

export function UserCard({ userId }) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
let ignore = false;
setIsLoading(true);
fetchUser(userId).then((result) => {
if (!ignore) {
setUser(result);
setIsLoading(false);
}
});
return () => { ignore = true; };
}, [userId]);

if (isLoading) return <Spinner />;
return (
<div className="card">
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}

It works. But look at what the component is burdened with: a state slot for data, a state slot for the flag, an effect with a cleanup race guard, and an early return that forks the UI in two. Every data-driven component in the app repeats this ceremony, and every one can get it subtly wrong (stale responses, forgotten resets on userId change…).

The Suspense version moves the pending concern out of the component and into the boundary:

import { Suspense, use } from 'react';
import { Spinner } from './Spinner';

function UserCard({ userPromise }) {
const user = use(userPromise); // ready? return it. not ready? boundary handles it.
return (
<div className="card">
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}

export function UserCardSection({ userPromise }) {
return (
<Suspense fallback={<Spinner />}>
<UserCard userPromise={userPromise} />
</Suspense>
);
}

What happens:

  1. UserCard is written as if the data is always there, because from its perspective, it is. The function only ever completes when the data exists.
  2. If the promise isn't resolved when use() runs, the "not ready" signal goes to the boundary, which shows the spinner.
  3. The component has zero loading states, zero effects, zero cleanup logic.

Read UserCard again. It describes one thing: what a user card looks like. That's the declarative ideal React has been aiming at since the beginning, Suspense finally extends it to asynchronous data.

Where does the real content go while the fallback shows?

A natural worry: when the spinner replaces Profile, is the half-built Profile thrown away?

No. While the fallback is on screen, React keeps working on the real tree in the background. When the promise resolves, React re-renders the boundary's children, and if that render completes, it swaps the finished tree in. Even better: if the real content had already been on screen once (say you're re-fetching), React can keep it mounted but hidden, preserving its state and DOM, and reveal it later intact. Chapter 3 of this part shows that hidden-tree behavior with a demo where a counter survives being suspended.

For now, hold this picture: the fallback is a placeholder layer, not a deletion. The real UI is being prepared underneath.

Diagram

Rendered diagram (PNG hi-res):

rendered diagram

Rendered diagram (PNG hi-res):

rendered diagram

Common misconceptions

  • Misconception: Suspense fetches your data. Reality: Suspense never fetches. It only displays fallbacks while things you started elsewhere are in flight.
  • Misconception: Suspense is only for React.lazy code splitting. Reality: code and data are the same mechanism to a boundary, anything represented by a promise can suspend.
  • Misconception: You must rewrite your app to use Suspense. Reality: boundaries are opt-in and composable; you can wrap one widget and leave everything else alone.
  • Misconception: The fallback prop is a loading state you manage. Reality: you never toggle it; the boundary shows it automatically whenever anything inside is un-ready.
  • Misconception: When the fallback shows, the real tree is destroyed. Reality: the real content is built in the background, and an already-visible tree can be kept mounted-but-hidden with its state intact.
  • Misconception: Suspense replaces error handling. Reality: "not ready" and "failed" are different signals with different boundaries, Suspense catches the first, error boundaries (chapter 5) catch the second.

Why it works this way

  • Declarative beats boolean bookkeeping. A hand-rolled isLoading flag can disagree with reality (forgot to reset it, stale closure, race between two fetches). A boundary can't disagree, it derives pending-ness from the render itself.
  • Throwing decouples the needing from the handling. Profile shouldn't know what your app's spinner looks like, and your spinner shouldn't know what a profile is. The thrown promise is the narrow interface between them.
  • One boundary, any depth. Whether one component suspends or twelve do, one boundary covers them. You get to choose the granularity of loading UI as a design decision, not as a side effect of where your if (loading) checks happen to be.
  • Purity makes it safe to throw. Renders are throwaway calculations (Part 2). Discarding a half-rendered tree costs nothing and corrupts nothing, which is why "throw and retry" is a viable strategy at all.

Try it yourself

  1. Wrap an existing component that uses useEffect + isLoading in a <Suspense fallback> instead, using use() to read a cached promise. Count the lines you deleted. Expected: the component gets dramatically shorter, and the behavior stays the same.
  2. Render <Suspense fallback={<p>outer</p>}><Suspense fallback={<p>inner</p>}><LazyThing /></Suspense></Suspense>. Which fallback appears? Expected: inner, the nearest boundary wins.
  3. Put a console.log('rendered') inside a component that suspends. Trigger it. Expected: you see the log fire on the attempt that suspends and again on the retry after resolution, proof the first render was discarded and redone.

Recap

  • <Suspense fallback={...}> declares: "if anything inside isn't ready, show this instead."
  • Components throw their not-ready state; boundaries catch it, try/catch for pendingness.
  • "Ready" covers two things: code (lazy bundles) and data (promises read with use() or a Suspense-aware library).
  • Suspense is not a fetching library, a boolean manager, or tied to any fetching approach.
  • Suspense moves the pending concern out of every component and into one boundary.
  • While the fallback shows, the real content is being built in the background.

Next

Throwing promises: the mechanism →