Suspense replaces scattered isLoading flags with declarative loading UI and is the foundation of streaming and data loading in React 19.
Suspense lets a component "wait" for something before rendering, while you declare the loading UI once, higher up:
<Suspense fallback={<Spinner />}>
<Comments />
</Suspense>If anything inside suspends, React shows the nearest boundary's fallback until it's ready, then reveals the content. Things that suspend:
- `lazy` components:
const Editor = lazy(() => import("./Editor.jsx"))loads code on first render, which is how you split big bundles by route or feature. - `use(promise)` in React 19: reads the value of a promise during render. If it's pending, the component suspends; if it rejects, the nearest error boundary catches it.
- Suspense-enabled data sources: framework data loading, Server Components, TanStack Query's
useSuspenseQuery.
use is special among Hooks: it can be called conditionally and in loops. The promise must be stable: created outside rendering (by a cache, a router loader or a parent) or passed from a Server Component. A promise created in the same component's render is new every time and would suspend forever. Suspense does not detect data fetched in an effect.
Design notes:
- Place boundaries where a loading state makes sense to the user. Nested boundaries reveal content progressively, outer first.
- Siblings inside one boundary are revealed together, avoiding a pop-in cascade.
- When an update would hide already-visible content behind a fallback, wrap it in a transition (next lesson) and React keeps showing the old UI instead.
- Since React 19, when a component suspends, React shows the fallback immediately and then "pre-warms" siblings, so commits aren't delayed by sibling rendering.
Example
import { Suspense, lazy, use, useState } from "react";
const Chart = lazy(() => import("./Chart.jsx")); // separate bundle chunk
// A simple cache so the promise is stable across renders
const cache = new Map();
function fetchUser(id) {
if (!cache.has(id)) {
cache.set(id, fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then(r => r.json()));
}
return cache.get(id);
}
function UserName({ id }) {
const user = use(fetchUser(id)); // suspends until resolved
return <h2>{user.name}</h2>;
}
export default function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<>
<Suspense fallback={<p>Loading user...</p>}>
<UserName id={1} />
</Suspense>
<button onClick={() => setShowChart(true)}>Show chart</button>
{showChart && (
<Suspense fallback={<p>Loading chart...</p>}>
<Chart />
</Suspense>
)}
</>
);
}Remember
- Suspense shows the nearest boundary's fallback while children are not ready
lazy(() => import(...))code-splits components;use(promise)reads promises during render- Promises passed to
usemust be stable (cached or created outside render)
Watch out for
- Calling
use(fetch(url))directly in render, creating a new promise every render. - Expecting Suspense to show a fallback for data fetched in a useEffect.