Suspense & lazy
Loading UI, declaratively
Section titled “Loading UI, declaratively”Managing loading states by hand means threading isLoading booleans through your components and rendering spinners in a dozen places. Suspense replaces that with a boundary: you wrap part of the tree, give it a fallback, and while anything inside is “not ready,” React shows the fallback instead.
import { Suspense } from "react";
function Page() { return ( <Suspense fallback={<Spinner />}> <Profile /> {/* if this suspends, the Spinner shows */} <Timeline /> {/* until BOTH are ready, then both appear together */} </Suspense> );}You don’t write “if loading show spinner” anywhere. The boundary owns the loading UI for its subtree, declaratively.
What “suspend” means
Section titled “What “suspend” means”A component suspends when it can’t finish rendering yet because it’s waiting on something — code that hasn’t downloaded (React.lazy), or data (via the use() hook or a Suspense-aware data library). When a component suspends, React “unwinds” to the nearest <Suspense> above it and shows that boundary’s fallback. When the awaited thing resolves, React retries the render and swaps the real content in.
flowchart TB boundary["Suspense fallback=Spinner"] --> child["Child suspends (waiting on code or data)"] child --> show["React shows Spinner"] show --> resolve["awaited thing resolves"] resolve --> retry["React retries render → shows real content"]
Code-splitting with React.lazy
Section titled “Code-splitting with React.lazy”The most common use of Suspense is lazy-loading a component so its code isn’t in the initial bundle. React.lazy takes a dynamic import() and returns a component that suspends until the chunk downloads.
import { lazy, Suspense } from "react";
// SettingsPanel's code is a separate chunk, fetched only when first rendered.const SettingsPanel = lazy(() => import("./SettingsPanel"));
function App({ showSettings }) { return ( <Suspense fallback={<Spinner />}> {showSettings && <SettingsPanel />} </Suspense> );}Until SettingsPanel is actually rendered, its JavaScript is never downloaded — a direct win for initial load time. When it first renders, it suspends (downloading), the fallback shows, then the panel appears.
Nesting boundaries
Section titled “Nesting boundaries”Suspense boundaries nest, and you place them to control the loading granularity. A boundary high in the tree means “show one big spinner for everything below.” Boundaries deeper down mean “reveal the shell immediately, and show small spinners only for the parts still loading.”
<Suspense fallback={<PageSkeleton />}> <Header /> <Suspense fallback={<FeedSkeleton />}> <Feed /> {/* the header shows while only the feed loads */} </Suspense></Suspense>Choosing where boundaries go is a UX decision: fewer boundaries = simpler but more “all or nothing”; more boundaries = progressive reveal.
Nested boundaries also control ordering: an outer boundary that’s already ready keeps its content visible while an inner one still shows its own fallback — the outer fallback never reappears just because something deeper suspended.
import { use, Suspense } from 'react';import { render, screen, act } from '@testing-library/react';
// A stable, module-level cached promise — see "use() needs a stable// promise" above for why an uncached one would suspend forever. The test// resolves it explicitly, so timing cannot make the assertion flaky.let resolveFeed!: (value: string) => void;const feedPromise = new Promise<string>((resolve) => { resolveFeed = resolve; });function SlowFeed() { const data = use(feedPromise); return <p data-testid="feed">{data}</p>;}
function NestedDemo() { return ( <Suspense fallback={<p data-testid="page-fallback">page loading…</p>}> <h1 data-testid="header">Header</h1> <Suspense fallback={<p data-testid="feed-fallback">feed loading…</p>}> <SlowFeed /> </Suspense> </Suspense> );}
test('nested Suspense: the inner fallback shows without hiding already-ready outer content', async () => { await act(async () => { render(<NestedDemo />); }); // Immediately after mount: the header (outer content) is visible, the // inner fallback shows for the still-loading feed, and the OUTER // (page-level) fallback never appears at all. expect(screen.getByTestId('header')).toBeTruthy(); expect(screen.getByTestId('feed-fallback')).toBeTruthy(); expect(screen.queryByTestId('page-fallback')).toBeNull();
await act(async () => { resolveFeed('feed-data'); }); expect(screen.getByTestId('feed').textContent).toBe('feed-data');});✓ nested Suspense: the inner fallback shows without hiding already-ready outer contentSuspense batching in React 19.2 (SSR streaming)
Section titled “Suspense batching in React 19.2 (SSR streaming)”React 19.2 changed how multiple ready Suspense boundaries reveal during server-streamed rendering:
“React reveals suspended content at most once every 300ms, measured from the last reveal. Boundaries that become ready within that window are revealed together rather than one at a time.” — react.dev blog, React 19.2
This is specifically a streaming SSR optimization (it batches how the server flushes ready boundaries to the client), not something a client-only Vite probe like this course’s harness can observe — there’s no SSR stream here to measure. Take this as an accurate, cited fact rather than a locally-reproduced one: if you see several sections of a server-rendered page “pop in” together instead of one-by-one, this batching window is why.
use() needs a stable promise
Section titled “use() needs a stable promise”use() doesn’t fetch or cache anything — it just reads whatever promise you hand it. Passing a promise created fresh on every render means every retry creates a new pending promise, so the component keeps re-suspending indefinitely instead of ever settling on real content. A cached promise (same instance across renders) resolves exactly once.
import { use, Suspense } from 'react';import { render, screen, act } from '@testing-library/react';
let uncachedCalls = 0;function BadResource({ id }: { id: string }) { uncachedCalls++; // New promise every render — even though it resolves in 5ms, by the time // it does, this exact render has already been abandoned for a newer one. const data = use(new Promise<string>((resolve) => setTimeout(() => resolve(`data:${id}`), 5))); return <p data-testid="bad-content">{data}</p>;}
const cache = new Map<string, Promise<string>>();let cachedCalls = 0;function GoodResource({ id }: { id: string }) { cachedCalls++; if (!cache.has(id)) { cache.set(id, new Promise((resolve) => setTimeout(() => resolve(`data:${id}`), 5))); } const data = use(cache.get(id)!); return <p data-testid="good-content">{data}</p>;}
test('an uncached (new-every-render) promise never resolves the fallback away', async () => { await act(async () => { render( <Suspense fallback={<p data-testid="bad-fallback">loading…</p>}> <BadResource id="x" /> </Suspense>, ); }); await act(async () => { await new Promise((r) => setTimeout(r, 60)); }); expect(screen.queryByTestId('bad-content')).toBeNull(); // still stuck on the fallback expect(screen.getByTestId('bad-fallback')).toBeTruthy(); expect(uncachedCalls).toBeGreaterThan(1); // re-invoked repeatedly — a real retry loop});
test('a cached (stable) promise resolves exactly once', async () => { await act(async () => { render( <Suspense fallback={<p data-testid="good-fallback">loading…</p>}> <GoodResource id="y" /> </Suspense>, ); }); await act(async () => { await new Promise((r) => setTimeout(r, 60)); }); expect(screen.getByTestId('good-content').textContent).toBe('data:y');});✓ an uncached (new-every-render) promise never resolves the fallback away✓ a cached (stable) promise resolves exactly oncelazy + retry on a failed import
Section titled “lazy + retry on a failed import”React.lazy(factory) calls factory once and caches its promise on that one lazy() instance forever — including a rejection. If the chunk fails to load (a flaky network, a stale deploy), re-rendering the same lazy() component does not retry the import; you must create a new lazy() instance. The common pattern: keep the lazy component in state, keyed by an “attempt” counter, so clicking “retry” produces a fresh lazy() call.
import { useState, useMemo, lazy, Suspense, Component, type ReactNode } from 'react';import { render, screen, act, fireEvent } from '@testing-library/react';
let importAttempts = 0;function flakyImport() { importAttempts++; if (importAttempts === 1) return Promise.reject(new Error('chunk load failed')); return Promise.resolve({ default: () => <p data-testid="panel">loaded</p> });}
class RetryBoundary extends Component<{ children: ReactNode; onRetry: () => void }, { error: Error | null }> { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } render() { if (this.state.error) { return ( <button data-testid="retry" onClick={() => { this.setState({ error: null }); this.props.onRetry(); }} > retry </button> ); } return this.props.children; }}
function LazyDemo() { const [attempt, setAttempt] = useState(0); // A fresh lazy() instance per attempt — the old, permanently-rejected one is discarded. const LazyPanel = useMemo(() => lazy(flakyImport), [attempt]); return ( <RetryBoundary onRetry={() => setAttempt((a) => a + 1)}> <Suspense fallback={<p data-testid="lazy-fallback">loading…</p>}> <LazyPanel /> </Suspense> </RetryBoundary> );}
test('lazy + retry: a failed import needs a brand new lazy() instance, not a re-render of the old one', async () => { await act(async () => { render(<LazyDemo />); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByTestId('retry')).toBeTruthy(); expect(importAttempts).toBe(1);
await act(async () => { fireEvent.click(screen.getByTestId('retry')); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(screen.getByTestId('panel').textContent).toBe('loaded'); expect(importAttempts).toBe(2);});✓ lazy + retry: a failed import needs a brand new lazy() instance, not a re-render of the old onekey to reset a suspended subtree
Section titled “key to reset a suspended subtree”React’s own docs describe this pattern directly for Suspense: put a key on the boundary (or a component above it) so that when the key changes, React treats it as different content and shows the fallback again — instead of leaving the previous key’s (now stale) content on screen.
“The
keycan go on the boundary itself or on a component above it. Suspense-integrated routers should do this automatically.” — react.dev,<Suspense>
import { use, Suspense, type ReactElement } from 'react';import { render, screen, act } from '@testing-library/react';
const cache = new Map<string, Promise<string>>();function resourceFor(id: string) { if (!cache.has(id)) { cache.set(id, new Promise((resolve) => setTimeout(() => resolve(`bio:${id}`), 10))); } return cache.get(id)!;}
function Bio({ id }: { id: string }) { const data = use(resourceFor(id)); return <p data-testid="bio">{data}</p>;}
function Profile({ userId }: { userId: string }) { // key={userId} on the boundary: changing userId forces the fallback to // show again instead of keeping the previous user's bio on screen. return ( <Suspense key={userId} fallback={<p data-testid="fallback">loading…</p>}> <Bio id={userId} /> </Suspense> );}
test('key on a Suspense boundary forces the fallback to show again on change, instead of leaving stale content', async () => { let rerender!: (ui: ReactElement) => void; await act(async () => { ({ rerender } = render(<Profile userId="alice" />)); }); await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); expect(screen.getByTestId('bio').textContent).toBe('bio:alice');
await act(async () => { rerender(<Profile userId="bob" />); }); // Immediately after the key change, before bob's promise resolves: the // fallback shows again (not alice's stale bio). expect(screen.getByTestId('fallback')).toBeTruthy(); expect(screen.queryByTestId('bio')).toBeNull();
await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); expect(screen.getByTestId('bio').textContent).toBe('bio:bob');});✓ key on a Suspense boundary forces the fallback to show again on change, instead of leaving stale contentMental Model
Section titled “Mental Model”A Suspense boundary is a contract, not a loading spinner: “everything below me must be ready before I show real content; until then, here’s the fallback.” use() is how a component tells that contract “I’m not ready” — by handing back a pending promise instead of a value. The promise’s identity is the whole contract: a fresh promise every render is like signing a new, never-ending contract every time instead of waiting for the one you already signed to be fulfilled. key on a boundary is how you deliberately tear up the old contract and sign a brand new one — on purpose, when the underlying content really did change (a different user, a different page).
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
A cached promise for use() | Resolves once, component settles | You (or a framework/library) must own a cache keyed by request identity |
| More/deeper Suspense boundaries | Finer loading granularity, faster perceived shell | More fallback UI to design; more places to reason about |
lazy() retry via a fresh instance | Recovers from a genuinely transient chunk-load failure | You must track an “attempt” key yourself — lazy() alone never retries |
key on a Suspense boundary | Forces a clean reset when content identity really changed | Always re-shows the fallback — wrong choice for “just refreshing the same thing” |
Common Misconceptions
Section titled “Common Misconceptions”- A new promise every render is “just a re-fetch.” It isn’t a single re-fetch — it’s a new, never-ending pending promise from
use()’s point of view every single time, so the component can suspend indefinitely, proven above. React.lazy(factory)retries automatically if you just re-render it. It doesn’t — the factory’s promise (including a rejection) is cached on that onelazy()call forever. A retry needs a newlazy()instance.- Suspense fallbacks always show “loading,” never “stale.” Without a
key, changing the underlying data (e.g. navigating to a different user) can leave the previous content on screen if nothing forces a fresh suspend —keyis exactly the tool for “I want this to reset, not linger.”
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated code that uses use(), lazy, or Suspense, check for:
- Is the promise passed to
use()created inside the component body (e.g.use(fetch(...).then(...))directly), instead of read from a cache/framework? That’s the infinite-fallback bug proven above. - After a
lazy()chunk fails, does the “retry” handler just reset an error-boundary’s error state without producing a newlazy()instance? The retry will call the exact same permanently-rejected promise again. - Does a route-level Suspense boundary lack a
keytied to the route/resource identity, even though the framework or code comments imply “this should reset per page”? Stale content can linger. - Is a
<Suspense>wrapped around something that never actually suspends, just “to be safe”? It’s harmless but dead weight — check whether any child can actually suspend before keeping the boundary.
🐛 Spot the bug
function Albums({ artistId }) {const albums = use(fetch(`/api/artists/${artistId}/albums`).then(r => r.json()));return <ul>{albums.map(a => <li key={a.id}>{a.title}</li>)}</ul>;}
function ArtistPage({ artistId }) {return ( <Suspense fallback={<Spinner />}> <Albums artistId={artistId} /> </Suspense>);}Show the answer
fetch(...) runs directly inside Albums’s render, so it creates a brand-new promise on every render — including every retry React performs after the previous promise resolves. Because use() never caches, this component suspends, the fetch resolves, React retries, the render body runs again, a new promise is created and passed to use(), and the cycle repeats — the fallback (<Spinner />) never goes away in practice. The fix is a cache keyed by artistId (if (!cache.has(artistId)) cache.set(artistId, fetch(...))) so the same promise instance is reused across retries.