Skip to content

React Suspense in 2026: The Patterns I Actually Use

React Suspense in 2026: The Patterns I Actually Use

Confession: I avoided React Suspense for almost two years. Every time I tried it, something weird happened. The whole tree would re-mount, my loading states would flicker for 80ms and then vanish, and I’d quietly back out and slap a useState boolean on the component. I told myself the API was overengineered.

I was wrong, and I want to spend a few minutes telling you why.

About six months ago I had to ship a dashboard with eight independent panels, each pulling from different APIs with wildly different latencies. The old isLoading boolean approach turned my JSX into a Christmas tree of ternaries. So I bit the bullet and rebuilt the whole thing around Suspense boundaries. It took me two weeks of swearing at hydration warnings. The result was so much better than what I had before that I’ve been quietly converting everything else since.

Short version if you’re in a hurry: Suspense isn’t magic, but it’s the closest thing React gives you to a real loading-state primitive. Here are the patterns I keep coming back to.

The mental model that finally clicked

For the longest time I thought of Suspense as a hook. It’s not. It’s a boundary, a place in your component tree where you say “everything below me might be waiting on something async, and if it is, render this fallback instead.”

The thing that confused me: components don’t tell Suspense they’re loading. They throw a promise. The nearest Suspense ancestor catches it, renders the fallback until the promise resolves, then re-renders the children. You don’t write the throwing code yourself. Libraries like use (React 19), TanStack Query, or Relay do it for you.

Once I internalised “Suspense is try/catch for promises”, the rest of the API stopped feeling weird.

Here’s the boilerplate I had before, taken from real code:

function Dashboard() {
  const { data, isLoading, error } = useUserMetrics()
  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error} />
  return <Metrics data={data} />
}

Multiply that by eight panels and you have a wall of ceremony.

After:

function Dashboard() {
  return (
    <ErrorBoundary fallback={<ErrorMessage />}>
      <Suspense fallback={<Spinner />}>
        <Metrics />
      </Suspense>
    </ErrorBoundary>
  )
}

function Metrics() {
  const data = use(userMetricsPromise)
  return <MetricsView data={data} />
}

Less code, but more importantly the loading concern has moved out of the data-rendering component. Metrics never has to think about the loading state.

Place boundaries where loading is local, not global

The first thing I got wrong: I wrapped my whole page in a single Suspense boundary. The result was that one slow API call would block the entire dashboard with one giant spinner. That’s worse than the old approach, not better.

The pattern that worked: one Suspense per independently-loading region. The official React docs on Suspense boundaries say this explicitly, but it didn’t click until I saw my own page staring back at me.

function Dashboard() {
  return (
    <Grid>
      <Suspense fallback={<PanelSkeleton />}>
        <RevenuePanel />
      </Suspense>
      <Suspense fallback={<PanelSkeleton />}>
        <UsersPanel />
      </Suspense>
      <Suspense fallback={<PanelSkeleton />}>
        <ChurnPanel />
      </Suspense>
    </Grid>
  )
}

Now panels render as their data arrives. The 200ms one paints first, the 1.4s one paints later. Users get feedback the whole way through instead of staring at a single spinner for the worst-case latency.

The mental rule I use: every visually independent region with its own data source gets its own boundary.

Pair Suspense with use() for promise-based reads

React 19’s use hook is the missing piece that makes Suspense feel native. You pass it a promise, and if the promise hasn’t resolved yet it throws and Suspense catches. If it’s resolved, you get the value back synchronously.

Caveat that bit me hard: you can’t create the promise inside the rendering component, because every render would create a new one and you’d loop forever. The promise has to come from a stable source: a parent prop, a context, or a cached factory.

// In a server component or stable factory:
const userPromise = fetchUser(id)

// In the client component:
function UserCard({ promise }) {
  const user = use(promise)
  return <h2>{user.name}</h2>
}

For ad-hoc fetches in client components I still reach for TanStack Query, which integrates with Suspense via its useSuspenseQuery hook and handles the cache-stability problem for me. I covered the wider state-and-data picture in my post on React 19 features I actually use six months in, and that’s still my default for anything more than a one-off read.

ErrorBoundary belongs everywhere Suspense does

Suspense catches loading. It does not catch errors. If your async code rejects, you’ll get an unhandled promise rejection and a blank screen unless you also wrap with an ErrorBoundary.

The pattern I use is to think of them as a matched pair, one in and one out:

<ErrorBoundary fallback={<RetryPanel />}>
  <Suspense fallback={<Skeleton />}>
    <ChildThatMightSuspend />
  </Suspense>
</ErrorBoundary>

I wrote a tiny snippet that codegens both at once whenever I drop in a Suspense boundary, because I forgot the error case three times in production before learning my lesson. React’s docs are pretty clear on this in the error boundaries reference, but it’s the kind of thing you only really feel after the second 3am Sentry page.

The streaming SSR thing nobody warned me about

Here’s the part that surprised me most. In the App Router and other streaming-SSR setups, Suspense boundaries are also flush points. The server sends down the HTML for everything outside a boundary first, then streams in each child as its data resolves. Coarse-grained boundaries kill this. Fine-grained ones make a real difference to perceived performance.

My dashboard’s Time-To-First-Byte dropped from around 1.4 seconds to 280ms once I broke the page into per-panel boundaries. Same backend, same total work, just letting the browser paint the chrome and the fast panels before the slow ones finished. The Next.js streaming docs walk through this if you want the full picture, but the punchline is that boundary placement is now a performance decision, not just an aesthetic one.

If you’ve ever measured a Web Vital and felt your stomach drop, this is the lever I’d reach for first.

When I don’t reach for Suspense

The Suspense-everywhere take has gotten a little out of hand. There are still cases where it’s the wrong tool:

  • Form submission state. Use useActionState or useFormStatus. I wrote about that in React’s useActionState: the hook that replaced my form reducers.
  • Optimistic UI. Use useOptimistic. Suspense fallbacks during a button click look terrible.
  • Mutations and long-running side effects. Manual state still wins. Suspense is built for reads; mutations are a different beast.
  • Tight loading windows. If your data resolves in under 50ms, a Suspense fallback will flash and feel buggier than no indicator at all. Use useTransition or just don’t bother.

I keep a one-line rule taped to my monitor: Suspense is for reads that take long enough that the user notices. Everything else is regular React.

What to try this week

If you’ve been avoiding Suspense the way I was, pick one route in your app that loads multiple independent pieces of data. Wrap each piece in its own Suspense plus ErrorBoundary. Move the data fetch inside each child. Run it. Watch the page paint progressively instead of all-or-nothing. The first time I saw it work I closed the laptop and went for a walk because it felt that good.

If you want more posts like this, practical writeups from someone who shipped the thing and got the bruises, I keep a running list of recent work and projects on my portfolio. And if you have a Suspense pattern you’ve found that I haven’t, please send it. I’m always happy to be told I’m holding it wrong.