Skip to content

React Suspense in 2026: The Loading States I Stopped Writing

React Suspense in 2026: The Loading States I Stopped Writing

Confession: I avoided Suspense for data fetching for about three years because every explanation I read started with “React throws a promise” and my brain filed that under “clever thing that will eventually hurt me.”

Then I counted the loading state code in one of our dashboards. Nine components, each with its own loading, error, and data triple, each with a slightly different spinner because three different people wrote them. Roughly 200 lines whose entire job was to say “not yet.”

I deleted most of it in an afternoon. Here’s what I got right, what I got wrong, and the part that still trips me up.

The pattern I was writing before

This is the shape almost every React codebase has somewhere:

function Profile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetchUser(userId)
      .then((u) => { if (!cancelled) setUser(u); })
      .catch((e) => { if (!cancelled) setError(e); })
      .finally(() => { if (!cancelled) setLoading(false); });
    return () => { cancelled = true; };
  }, [userId]);

  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
  return <h1>{user.name}</h1>;
}

Twenty lines, and four of them exist purely to handle the case where the component unmounts mid request. I have shipped the version of this without the cancelled flag more than once and then spent an afternoon confused about a state update warning.

The same component with use() and Suspense

React 19 shipped use, which reads a promise during render and hands the waiting off to the nearest Suspense boundary. The reference docs for use are short and worth reading in full, partly because of one detail: despite the naming, use isn’t a hook, so you can call it inside conditionals and loops.

function Profile({ userPromise }) {
  const user = use(userPromise);
  return <h1>{user.name}</h1>;
}

function Page({ userId }) {
  const userPromise = useMemo(() => fetchUser(userId), [userId]);
  return (
    <ErrorBoundary fallback={<ErrorMessage />}>
      <Suspense fallback={<Spinner />}>
        <Profile userPromise={userPromise} />
      </Suspense>
    </ErrorBoundary>
  );
}

The component that renders the data now only knows about data. Loading moved to Suspense, failure moved to the error boundary, and the unmount race disappeared because there’s no setState to lose.

That split is the actual win. Not fewer lines, though there are fewer lines. The win is that each concern lives in exactly one place instead of being copy pasted into every leaf component by whoever wrote it that sprint.

The caching problem I walked straight into

Here’s where I lost an afternoon.

My first version didn’t have the useMemo. It called fetchUser(userId) directly in the parent’s render body. Which meant every parent re-render created a fresh promise, use saw a new promise, Suspense fell back to the spinner, and the network tab filled up with duplicate requests. The UI flickered on every unrelated state change and I blamed React for a while before reading my own code.

The rule: the promise has to be stable across renders, or you’re building an infinite fetch loop with extra steps.

useMemo is the duct tape version and it’s fine for simple cases, but React explicitly doesn’t guarantee memo persistence. In real applications you want a cache that lives outside the component. That’s most of what React Query and SWR are actually selling, and it’s why “Suspense replaces your data library” was never true. Suspense replaces your loading state. It doesn’t replace your cache, your deduplication, your revalidation, or your retry policy.

The Suspense reference is direct about the boundaries of what it handles, and the React 19 release notes cover how use interacts with the rest of the release.

Suspense doesn’t catch errors, and I keep forgetting

A rejected promise passed to use doesn’t get handled by Suspense. It propagates up to the nearest error boundary. If you don’t have one, your user sees a blank white page.

So use without a wrapping error boundary is strictly worse than the useEffect version, because at least the old one had an error branch. I made this mistake in a staging deploy and only caught it because someone’s auth token expired at the right moment.

The pairing is not optional:

<ErrorBoundary fallback={<Retry />}>
  <Suspense fallback={<Skeleton />}>
    <Dashboard />
  </Suspense>
</ErrorBoundary>

I went deeper on the boundary side of this in how I stopped shipping white screens, including where to put boundaries so one failed widget doesn’t take down a whole page.

The other Suspense, the one you might already be using

Worth separating two things that share a name, because I conflated them for years and it made the docs harder to read than they needed to be.

Suspense shipped originally for code splitting, paired with lazy. That version has been stable and boring since React 16.6:

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

<Suspense fallback={<Skeleton />}>
  <Settings />
</Suspense>

Same boundary, same fallback, different thing being waited on. Here it’s a JavaScript chunk over the network rather than your data. The lazy reference covers it, and if you’re on a framework you’re probably already getting this without writing it yourself.

That’s the useful mental model: a Suspense boundary is a declaration that says “something below me isn’t ready, show this instead.” It doesn’t care whether the missing thing is a bundle or a database row. Once that clicked, the data fetching version stopped feeling like a new API and started feeling like the same API pointed at a different problem.

It also explains why boundaries stack sensibly. A lazy loaded route inside an outer boundary, with its own inner boundary around a slow widget, does the reasonable thing: the outer fallback covers the chunk download, the inner one covers the data, and the user sees the shell appear before the contents fill in.

Boundary placement is the part that takes taste

Once the mechanics work, the remaining question is where the boundaries go, and this is genuinely a judgment call rather than a rule.

One boundary at the top of the page is the easiest thing to write and usually the worst experience. The whole page waits on the slowest request in it. If your user avatar fetch is slow, your fast chart sits there being invisible for no reason.

A boundary around every single component goes the other way. You get a page of independently popping skeletons that reflow four times while it settles, which reads as broken even though every individual piece is doing something reasonable.

What I’ve landed on: one boundary per region that a user would describe as a thing. The sidebar. The chart. The comment list. If a person would name it out loud, it probably deserves its own fallback. If they wouldn’t, it can share one with its neighbour.

And make the fallback the right shape. A skeleton that matches the final layout stops the reflow entirely. A centered spinner in a container that later fills with a table means the page jumps, and layout shift is the kind of thing that feels cheap without anyone being able to say why.

What to do this week

Find the component in your codebase with the most useState(false) calls for loading. There’s always one. It’s usually the dashboard or the settings page.

Convert exactly that one. Wrap it in an error boundary, then a Suspense boundary with a skeleton shaped like the real content, hoist the fetch to a promise created outside render, and delete the loading branches. Keep it on a branch and click through it before you commit, particularly the case where the request fails, because that’s the path that silently regresses.

If you’re already on React Query or SWR, you don’t need use at all. Turn on the suspense mode your library already has and you get the same component level cleanup with the cache you’re currently relying on. That’s the boring answer and it’s the right one for most teams.

I keep working notes on migrations like this, including the ones I abandoned halfway, over on my site. The abandoned ones are usually more instructive.