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.
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 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
useEffectdata 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 withsuspense, 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.
Related articles
- React React SuspenseList Tutorial
Coordinate multiple Suspense boundaries with SuspenseList to control reveal order and loading states, reducing layout shift and chaos during streamed data fetching.
- React TanStack Query (React Query) for Data Fetching
Learn how TanStack Query replaces useEffect-based data fetching with caching, background refetching, and request deduplication that scales to real apps.
- Next.js Next.js Data Fetching Patterns: A Practical Guide
Compare the main Next.js data fetching strategies and learn when to use server components, route handlers, SWR, or static generation in your apps.
- Next.js Next.js Streaming and Suspense Boundaries
Learn how the App Router streams HTML over the wire and how Suspense boundaries control what users see while data loads.