React Suspense, Explained
React Suspense lets components pause rendering while they wait on async data, showing a fallback UI instead of manual loading-state juggling.
Suspense is a React mechanism that lets a component “pause” rendering while it’s waiting on something asynchronous — data, a lazily loaded module — and show a fallback UI in its place until that dependency resolves. Instead of every component manually tracking its own isLoading boolean and conditionally rendering a spinner, Suspense lets a parent component declare a fallback once and have React coordinate showing it, without the child component needing to know it’s being suspended.
The problem it replaces
Before Suspense, the standard pattern for async data in a component looked like this:
function Profile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser(userId).then(u => {
setUser(u);
setLoading(false);
});
}, [userId]);
if (loading) return <Spinner />;
return <div>{user.name}</div>;
}
This works, but every component that fetches data repeats the same loading/error/data triage, and coordinating multiple simultaneous fetches into one coherent loading state — so the page doesn’t flash five separate spinners at slightly different times — takes real effort to get right by hand.
How Suspense changes the shape
With Suspense, a component that isn’t ready to render simply throws a promise rather than returning loading markup. React catches that thrown promise, looks up the nearest enclosing <Suspense> boundary, and renders its fallback instead — then retries rendering the component once the promise resolves:
function Profile({ userId }) {
const user = useUser(userId); // a Suspense-compatible data hook
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<Profile userId="123" />
</Suspense>
);
}
Profile no longer contains any loading logic at all — it’s written as if the data were already there. The <Suspense> boundary is what decides what to show while it isn’t. This is the core shift: loading state moves from being a property of individual components to being a property of the tree structure around them.
You need Suspense-compatible data fetching
The important caveat: a plain fetch() call inside a component doesn’t suspend on its own. Suspense works with data sources built to integrate with it — frameworks with built-in data-fetching layers (Next.js and similar meta-frameworks), libraries designed for it, or React’s own use() hook, which can suspend on a promise passed to it. Reaching for <Suspense> around components using an ordinary useEffect-based fetch does nothing; the component was never going to throw a promise in the first place.
Suspense for code splitting
The other major use case predates data fetching support and is more broadly available: React.lazy() for dynamically imported components.
const Settings = lazy(() => import("./Settings"));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
);
}
Here, the “async work” being waited on is the component’s own JavaScript bundle downloading, rather than a data fetch. This use case works with plain client-side React and doesn’t require any particular data layer — it’s the most reliable place to reach for Suspense today if you’re not using a framework with deeper support built in.
Nesting boundaries and granularity
Suspense boundaries can nest, and where you put them controls the granularity of loading states. A single boundary around an entire page shows one fallback until everything inside is ready — simple, but it means a fast-loading sidebar waits on a slow-loading main panel. Wrapping each independently-loading section in its own boundary lets fast sections appear as soon as they’re ready while slower ones keep showing their own fallback, at the cost of more fallback UI to design and more boundaries to reason about. This is a deliberate trade-off, not a default to get right once — the right granularity depends on which parts of a given page genuinely benefit from appearing independently.
The takeaway
Suspense moves loading-state handling out of individual components and into the structure of the component tree, letting a component render as though its data is already there while a <Suspense> boundary above it decides what to show in the meantime. It requires data sources built to integrate with it to be useful for data fetching — a plain effect-based fetch won’t suspend — but works today, framework-independent, for lazy-loaded component code via React.lazy().
Tagged
Keep reading
Takina · · 5 min read Promise.all() vs allSettled() vs race() Compared
Promise.all() fails fast, allSettled() waits for every result, and race() returns whichever promise finishes first — how to choose correctly.
Takina · · 4 min read JavaScript Spread vs Rest Operators, Explained
The spread operator (...) expands an iterable into individual elements; the rest operator collects elements back into an array. Same syntax, opposite jobs.
Takina · · 4 min read ResizeObserver API Explained: Watching Element Size
The ResizeObserver API lets JavaScript watch an element's box size and react without polling or resize-event hacks. How it works and when to use it.