Suspense shipped in React 16 as a way to lazy-load components with React.lazy(), and for years that was mostly what it was used for. React 19 turns it into something closer to what the original pitch promised: a general mechanism for saying "this part of the tree isn't ready yet, show a fallback," whether the thing it's waiting on is a chunk of JavaScript, a data fetch, or a promise you're reading directly with the new use() hook.
Suspense was never really just for code-splitting
The mental model that stuck from React 16 was "Suspense pauses rendering until a lazy-loaded component's code arrives." That's accurate but incomplete — the actual contract is that any child can throw a promise during render, and the nearest Suspense boundary catches it, waits for the promise to resolve, then retries the render. Code-splitting was just the first thing anyone plugged into that contract. Data-fetching libraries like Relay and React Query have supported this "suspense mode" for a while, but it required each library to implement its own cache and its own throw-a-promise plumbing, and the ergonomics on the React side were awkward because there was no first-class hook for consuming a suspended value inside a component. React 19 closes that last gap with use().
use() inside a Suspense boundary
use() reads the value out of a promise. If the promise hasn't resolved yet, the component suspends — React unmounts it (without losing the rest of the tree's state) and shows the nearest fallback until the promise settles, then re-renders with the resolved value. Unlike a data-fetching hook, use() is not itself the thing that starts the fetch; it just consumes whatever promise you hand it.
import { use, Suspense } from 'react';
function ProfileCard({ userPromise }: { userPromise: Promise<User> }) {
// Suspends here until userPromise resolves; the component
// simply "waits" — no isLoading flag to check.
const user = use(userPromise);
return <h2>{user.name}</h2>;
}
export function ProfilePage({ userPromise }: { userPromise: Promise<User> }) {
return (
<Suspense fallback={<p>Loading profile…</p>}>
<ProfileCard userPromise={userPromise} />
</Suspense>
);
}
The promise itself has to come from somewhere stable — a cache, a router loader, or a parent component that created it once and passed it down as a prop. If ProfilePage called fetch() inline on every render and passed the fresh promise straight into use(), you'd get an infinite suspend-render loop, because a new promise identity looks like a new request every time.
Nested boundaries for granular loading states
Because any component can suspend independently, you can nest Suspense boundaries to get fine-grained loading states instead of one all-or-nothing spinner for the whole page. A dashboard can render its header and navigation immediately, then let a slow analytics widget suspend on its own boundary while the rest of the page is already interactive.
function Dashboard() {
return (
<>
<Header />
<Suspense fallback={<SkeletonSidebar />}>
<Sidebar />
</Suspense>
<Suspense fallback={<SkeletonChart />}>
<RevenueChart dataPromise={chartData} />
</Suspense>
</>
);
}
Each boundary resolves on its own schedule. The sidebar can pop in a second before the chart does, and neither one blocks the header from being visible and clickable immediately. This is the pattern that replaces the old "one big spinner, then everything at once" approach that a top-level Suspense boundary used to encourage by default.
React 19's Suspense retry logic is smarter about work it can do while a sibling boundary is still pending — if a lower-priority boundary's data is already available by the time React gets to it, it commits without an extra fallback flash. Kicking off fetches as early as possible (in a router loader, before the component tree even renders) still matters more than any scheduler improvement; Suspense can only hide latency you couldn't avoid, not eliminate it.
Error boundaries still do the failure half
Suspense only handles the "not ready yet" case. If the promise passed to use() rejects, that's a thrown error during render, and it needs an error boundary above the Suspense boundary to catch it — Suspense itself does not have a built-in error fallback. In practice I pair every Suspense boundary that wraps a use() call with an error boundary one level up, so a failed fetch renders a retry UI instead of taking down the rest of the page.
Wrapping up
The real change in React 19 isn't a new API surface for Suspense — it's that use() finally gives you a first-class way to suspend on a promise without a data-fetching library's bespoke cache doing the throwing for you. Combined with nested boundaries, that means loading states can match your actual data dependencies instead of the shape of your component tree. The catch is still the same one Suspense always had: the promise has to be stable, and the fallback tree needs an error boundary sitting above it.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.