Skip to content
C Codeloom
React

React Suspense and Data Fetching

How React Suspense works for data fetching: boundaries, the use() hook, streaming, and how to design loading states that do not feel janky.

·5 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What Suspense actually suspends
  • How the use() hook integrates with promises
  • How boundaries control loading UI
  • Streaming and waterfall avoidance
  • Common Suspense pitfalls

Prerequisites

  • Comfortable writing functions

Suspense was originally introduced for lazy-loading components, but its real power shows up in data fetching. It lets a component say “I am not ready yet” by throwing a promise, and lets a parent boundary show a fallback until that promise resolves. The result is a declarative model for loading states that composes naturally with the rest of React.

What and why

Without Suspense, every component that fetches data ends up with the same dance: a useState for data, another for loading, another for error, an effect to start the fetch, and a series of conditionals in the JSX. Suspense moves loading and error handling out of the component and into boundaries. The component just reads data; if the data is not there, React pauses and shows the nearest fallback.

Two things have to be true for this to work. First, the data source has to integrate with Suspense, meaning it knows how to throw a promise when data is missing. Second, you need a <Suspense> boundary somewhere above the component to catch the throw and render the fallback.

Mental model

Think of throwing a promise as a structured setTimeout that React catches. The component tells React “wake me when this is done.” React unwinds to the nearest boundary, renders the fallback, and re-renders the suspended subtree when the promise resolves.

<Suspense fallback={<Spinner />}>
 |
 v
<UserProfile />
 |
 v
read user data
 |
 +-- data ready -> render normally
 |
 +-- data missing -> throw promise
                          |
                          v
                React shows <Spinner />
                          |
                          v
                promise resolves -> retry render
Suspense lifecycle

Hands-on example

The use() hook unwraps a promise inside a component and integrates with Suspense automatically.

import { Suspense, use } from 'react';

async function fetchUser(id: string) {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
  const user = use(userPromise);
  return <h1>Hello, {user.name}</h1>;
}

export default function Page() {
  const userPromise = fetchUser('42');
  return (
    <Suspense fallback={<p>Loading user...</p>}>
      <UserProfile userPromise={userPromise} />
    </Suspense>
  );
}

The fetch starts in the parent and the promise is passed down. While unresolved, use() throws and React shows the fallback. When the promise settles, React re-renders UserProfile with the resolved value.

You can nest boundaries to control granularity:

<Suspense fallback={<PageSkeleton />}>
  <Header />
  <Suspense fallback={<ListSkeleton />}>
    <OrderList />
  </Suspense>
  <Suspense fallback={<ChartSkeleton />}>
    <RevenueChart />
  </Suspense>
</Suspense>

The header renders immediately. The list and chart each show their own skeleton and reveal independently as their data arrives. This is the essence of streaming UIs.

Avoiding waterfalls

A waterfall happens when component A fetches, finishes, and only then component B starts fetching. With Suspense it is easy to accidentally create them by starting fetches inside child components.

// Bad: B does not start until A resolves
function A() {
  const a = use(fetchA());
  return <B />;
}
function B() {
  const b = use(fetchB());
  return <span>{b.value}</span>;
}

Start fetches as high in the tree as you can, in parallel, then pass the promises down.

function Page() {
  const aPromise = fetchA();
  const bPromise = fetchB();
  return (
    <Suspense fallback={<Skeleton />}>
      <A promise={aPromise} />
      <B promise={bPromise} />
    </Suspense>
  );
}

Common pitfalls

  • Creating a new promise on every render. If you call fetchUser() inside a component without caching, every render starts a new request and Suspense never resolves.
  • Forgetting the boundary. A component that suspends without an ancestor <Suspense> will bubble up to the root and show a blank screen or trigger a hard error.
  • Mixing Suspense with useEffect data fetching in the same tree. The two models do not coordinate, and you end up with double-loading states.
  • Putting the boundary too high. A single boundary around the whole page collapses every loading state into one global spinner, defeating the streaming benefit.

Best practices

  • Pair every Suspense boundary with an Error Boundary so failed fetches do not blank the screen.
  • Use a Suspense-friendly data library (React Query with suspense: true, SWR with suspense, Relay, or your framework’s built-in loader).
  • Co-locate the boundary with the loading UI it controls. The smaller the scope, the more localized the fallback.
  • Start fetches outside the component when possible and pass promises in. This is what frameworks like Next.js do automatically in Server Components.

FAQ

Does Suspense work in client-only apps? Yes. The use() hook and Suspense for data work in client React 18+, though you need a data source that supports it.

What about error handling? Suspense only catches “not ready” signals. Errors are handled by Error Boundaries, which you compose alongside Suspense boundaries.

Can I suspend on multiple promises? Yes. Pass an array to Promise.all and use() the resulting promise, or call use() multiple times in the same component.

Does the fallback flash for fast data? React batches transitions to avoid that. Use startTransition for updates where you want the old UI to stay visible until the new data arrives.