Suspense for Data Fetching
Most data fetching code is dominated by the waiting — isLoading flags, ternaries that render spinners, and early returns scattered across every component that touches the network. Suspense flips that model around: instead of each component checking whether its data has arrived, a component simply suspends while it waits, and a single parent boundary decides what to show in the meantime. The result is loading UI that is declarative, composable, and far easier to coordinate across a tree.
What it means to suspend
A component “suspends” when, during render, it reads a resource that is not ready yet. React notices this, pauses that subtree, and walks up to find the nearest <Suspense> boundary, rendering that boundary’s fallback instead. When the data resolves, React retries the render and the real UI replaces the fallback — no useState, no useEffect, no manual loading branch.
The key idea is that the component code reads data as if it were already there:
import { Suspense } from "react";
function UserProfile({ userId }) {
const user = fetchUser(userId); // reads a resource; may suspend
return <h1>{user.name}</h1>;
}
function Page() {
return (
<Suspense fallback={<p>Loading profile…</p>}>
<UserProfile userId="42" />
</Suspense>
);
}
There is no loading branch inside UserProfile. The component describes only the success state; the <Suspense> boundary owns the loading state for everything beneath it.
The use hook
In React 19, use is the standard, blessed way to make a component suspend on a Promise. You pass it a Promise and it either returns the resolved value or suspends until the Promise settles. Unlike other hooks, use may be called conditionally and inside loops.
import { use, Suspense } from "react";
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // suspends until resolved
return (
<ul>
{comments.map((c) => (
<li key={c.id}>{c.text}</li>
))}
</ul>
);
}
function Thread({ commentsPromise }) {
return (
<Suspense fallback={<p>Loading comments…</p>}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
);
}
Create the Promise outside the component that calls
use, or cache it — never create a fresh Promise inside a render thatuseconsumes. A new Promise on every render means the value is never “the same,” so the component suspends forever. Frameworks and data libraries handle this caching for you; do not roll it by hand for real fetches.
Library integration
You rarely call use against a raw fetch Promise in production, because you still need caching, deduplication, and revalidation. Suspense is designed to be the rendering layer on top of a cache, and the major data libraries expose a Suspense mode that suspends instead of returning an isLoading flag.
import { useSuspenseQuery } from "@tanstack/react-query";
import { Suspense } from "react";
function Repos({ org }) {
const { data } = useSuspenseQuery({
queryKey: ["repos", org],
queryFn: () =>
fetch(`https://api.github.com/orgs/${org}/repos`).then((r) => r.json()),
});
return (
<ul>
{data.map((repo) => (
<li key={repo.id}>{repo.full_name}</li>
))}
</ul>
);
}
function ReposPanel() {
return (
<Suspense fallback={<p>Loading repositories…</p>}>
<Repos org="facebook" />
</Suspense>
);
}
With useSuspenseQuery, data is always defined inside Repos — the loading case can never reach the component body, so the type narrowing and the code both get simpler.
| API | Returns while loading | Loading UI owned by | Use when |
|---|---|---|---|
useEffect + fetch | null / loading flag | The component itself | Trivial one-off requests |
useQuery (TanStack) | isPending: true | The component (ternary) | You want explicit branches |
useSuspenseQuery | Suspends | <Suspense> boundary | Declarative loading UI |
use(promise) | Suspends | <Suspense> boundary | Framework-provided Promises |
Coordinating fallbacks and errors
Because boundaries are just components, you place them wherever you want the loading granularity to live. One boundary high in the tree gives a single page-level spinner; several smaller boundaries let independent sections stream in as their data arrives.
function Dashboard() {
return (
<main>
<Suspense fallback={<HeaderSkeleton />}>
<ProfileHeader />
</Suspense>
<Suspense fallback={<FeedSkeleton />}>
<ActivityFeed />
</Suspense>
</main>
);
}
Suspense handles the pending state, but not failure. Pair every boundary with an error boundary so a rejected fetch renders a recoverable error UI instead of crashing the tree.
import { ErrorBoundary } from "react-error-boundary";
import { Suspense } from "react";
function Section() {
return (
<ErrorBoundary fallback={<p>Could not load this section.</p>}>
<Suspense fallback={<p>Loading…</p>}>
<ActivityFeed />
</Suspense>
</ErrorBoundary>
);
}
On the server, the same boundaries enable streaming SSR: React sends the shell with fallbacks immediately, then streams each section’s HTML as its data resolves, so users see content progressively rather than waiting for the slowest query.
Best Practices
- Let components read data as if it is already present, and put loading UI in
<Suspense fallback>rather than in component branches. - Never create the Promise that
useconsumes inside render — cache it or let a library/framework own it. - Prefer a library’s Suspense mode (
useSuspenseQuery, SWRsuspense: true) over hand-fed Promises so you keep caching and dedup. - Wrap every Suspense boundary in an error boundary; Suspense covers pending, not failed.
- Choose boundary granularity deliberately — one boundary for a page-wide spinner, several for independent sections that stream in.
- Use skeletons that match the final layout to avoid jarring shifts when the fallback is replaced.