Suspense
Suspense is React’s declarative mechanism for handling the waiting state of a UI. Instead of scattering isLoading flags and conditional spinners throughout your components, you wrap a part of the tree in a <Suspense> boundary and give it a fallback to show while anything inside is still loading. When a component inside the boundary “suspends” — because its code or its data is not ready yet — React pauses rendering that subtree, shows the fallback, and seamlessly swaps in the real content once everything resolves.
How suspending works
A component suspends by throwing a promise during render. You almost never do this by hand; instead you use APIs that integrate with Suspense — React.lazy for code, and Suspense-enabled data sources such as frameworks (Next.js, React Router) or libraries (React Query, Relay) for data. When React encounters a thrown promise, it walks up the tree to the nearest <Suspense> boundary, renders that boundary’s fallback, and subscribes to the promise. When the promise settles, React retries the render.
The mental model: a <Suspense> boundary represents “a region of the UI that can be in a loading state as a single unit.” Everything inside resolves together before the fallback is replaced.
Lazy-loaded components
The most common entry point is code-splitting with React.lazy. It returns a component that loads its module on first render, suspending until the chunk arrives.
import { Suspense, lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard.jsx"));
export default function App() {
return (
<Suspense fallback={<p>Loading dashboard…</p>}>
<Dashboard />
</Suspense>
);
}
lazy accepts a function that returns a promise resolving to a module with a default export. Until that import resolves, Dashboard suspends and the fallback renders in its place.
Fallbacks and boundary placement
The fallback prop accepts any React node — text, a spinner, or a full skeleton component. Where you place the boundary controls the granularity of loading: one boundary high in the tree shows a single fallback for a large area, while several smaller boundaries let independent sections load on their own schedule.
import { Suspense } from "react";
import Sidebar from "./Sidebar.jsx";
import Feed from "./Feed.jsx";
import Recommendations from "./Recommendations.jsx";
function Skeleton({ label }) {
return <div className="skeleton" aria-busy="true">Loading {label}…</div>;
}
export default function Home() {
return (
<main>
<Suspense fallback={<Skeleton label="sidebar" />}>
<Sidebar />
</Suspense>
<Suspense fallback={<Skeleton label="feed" />}>
<Feed />
<Recommendations />
</Suspense>
</main>
);
}
Here the sidebar and the feed area load independently. Feed and Recommendations share a boundary, so they appear together once both are ready.
Place boundaries at meaningful UI seams — a panel, a route, a card list — not around every component. Too many boundaries produce a stuttering cascade of spinners; too few make the whole screen wait on its slowest part.
Suspense for data
Data fetching becomes declarative when paired with a Suspense-aware source. The component reads data synchronously, and the framework handles suspending until it resolves. With React Query, for example:
import { Suspense } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
function UserProfile({ userId }) {
const { data } = useSuspenseQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
});
return <h1>{data.name}</h1>;
}
export default function ProfilePage({ userId }) {
return (
<Suspense fallback={<p>Loading profile…</p>}>
<UserProfile userId={userId} />
</Suspense>
);
}
useSuspenseQuery never returns an undefined data — the component only renders once the query has resolved, because it suspends until then. This eliminates loading-state branching inside the component.
Combining Suspense with transitions
When you trigger a state change that causes a boundary to re-suspend (such as navigating to new data), you usually do not want the existing content to flash back to a fallback. Wrapping the update in a transition keeps the current UI visible while the new content loads in the background.
import { Suspense, useState, useTransition } from "react";
export default function Tabs({ panels }) {
const [active, setActive] = useState(0);
const [isPending, startTransition] = useTransition();
return (
<>
{panels.map((p, i) => (
<button key={p.id} onClick={() => startTransition(() => setActive(i))}>
{p.label}
</button>
))}
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<Suspense fallback={<p>Loading…</p>}>
{panels[active].content}
</Suspense>
</div>
</>
);
}
On the first render of a boundary, the fallback shows. On subsequent updates wrapped in startTransition, React keeps the old panel mounted and dims it via isPending until the new one is ready — no jarring spinner flash.
SSR streaming
On the server, Suspense unlocks streaming HTML. With renderToPipeableStream (Node) or renderToReadableStream (Web/edge), React sends the shell immediately and streams each boundary’s content as its data resolves, injecting it into the page in place of the fallback.
import { renderToPipeableStream } from "react-dom/server";
import App from "./App.jsx";
function handler(req, res) {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ["/client.js"],
onShellReady() {
res.setHeader("content-type", "text/html");
pipe(res);
},
});
}
The client then performs selective hydration: it hydrates each streamed boundary as it arrives and prioritizes whichever region the user interacts with first. Fast parts of the page become interactive without waiting for slow ones.
| Aspect | Without Suspense | With Suspense |
|---|---|---|
| Loading state | Manual isLoading flags | Declarative fallback |
| SSR delivery | Full page after all data | Streamed shell + progressive chunks |
| Hydration | All-at-once, blocking | Selective, prioritized by interaction |
| Re-fetch UX | Spinner flash | Smooth via transitions |
Suspense catches pending states, not errors. A rejected data promise will not be shown by the fallback — pair every boundary with an Error Boundary to handle failures gracefully.
Best practices
- Place boundaries at logical UI regions so each area’s loading state is independent and meaningful.
- Pair every
<Suspense>with an Error Boundary, since Suspense handles only the pending case, not rejection. - Use
useTransition(oruseDeferredValue) when updating data inside an already-mounted boundary to avoid fallback flashes. - Prefer Suspense-aware data libraries over hand-rolling promise throwing — manual implementations are easy to get wrong.
- Design skeleton fallbacks that match the final layout to minimize layout shift when content swaps in.
- On the server, stream with
renderToPipeableStream/renderToReadableStreamso slow boundaries never block the rest of the page.