A3 · Part 3 · Advanced
Suspense & code splitting
Declaring where a loading state lives, instead of threading one through every component.
09On this page
The idea
<Suspense fallback={<Skeleton />}>
<Profile /> // if this suspends, the skeleton shows
</Suspense>
A component "suspends" when it needs something that is not ready yet. React
stops rendering that subtree, shows the nearest boundary's fallback, and
retries when the thing arrives. The component itself contains no
isLoading check — the loading state moved out, to a place
that can describe the whole region.
The mechanism is a thrown promise, which is why older articles describe it
that way. You never throw one yourself: you reach it through
lazy, use, or a library that supports Suspense.
Code splitting, the everyday use
const Settings = lazy(() => import("./Settings"));
<Suspense fallback={<Spinner />}>
<Settings />
</Suspense>
lazy returns a component that suspends until its chunk arrives.
The natural split points are routes, heavy dialogs, and anything below the
fold that most visitors never open — an editor, a chart library, a map.
The module must have a default export; for a named one, map it:
lazy(() => import("./Chart").then((m) => ({ default: m.Chart }))).
Prefetch on intent, not on render: onMouseEnter={() => import("./Settings")}
starts the download while the pointer is travelling, so the click feels
instant.
The most common mistake
function Page() {
const Chart = lazy(() => import("./Chart")); // ✗ inside the component
}
A new lazy component on every render means a new type, so React unmounts and
remounts it every time — the chunk reloads and the fallback flashes
forever. lazy belongs at module scope.
Boundary placement is a design decision
// one boundary: nothing appears until everything is ready
<Suspense fallback={<PageSkeleton />}>
<Header /><Feed /><Sidebar />
</Suspense>
// three: each region appears as it becomes ready
<Suspense fallback={<HeaderSkeleton />}><Header /></Suspense>
<Suspense fallback={<FeedSkeleton />}><Feed /></Suspense>
Finer boundaries are not automatically better. Too many and the page assembles in visible stages, shifting layout as each lands. Aim for regions a user would describe as separate things, and give the fallback the same shape as the content so nothing moves when it swaps.
Nested boundaries reveal outside in
<Suspense fallback={<PageSkeleton />}>
<Article />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</Suspense>
While Article is loading, the page skeleton shows. When it is
ready the article appears, and if comments are still loading their own
skeleton takes their place. Nesting is how you say "this part may arrive
later, but that part must not". Whatever is inside the outer boundary and
outside the inner one appears together, as a unit.
React also paces the reveals: suspended content is revealed at most once every 300ms, and boundaries that become ready inside that window appear together instead of popping in one by one. Since React 19, when something suspends, the fallback is committed straight away and the suspended siblings are rendered afterwards to start their requests early, so the fallback appears faster than it did in React 18.
Suspense and transitions together
startTransition(() => setTab("analytics"));
Without the transition, switching to a tab whose data is not ready replaces
the current content with a fallback — the screen goes blank and comes
back. Inside a transition, React keeps the old content on screen until the new
one is ready, and isPending lets you dim it in the meantime.
This is the rule precisely: once a boundary is showing content, suspending
again brings the fallback back unless the update came from
startTransition or useDeferredValue. It is the
difference between a page that flashes on every navigation and one that feels
continuous, and it is why routers wrap navigation in a transition. When
content is hidden again, React cleans up its layout effects and runs them
again once it reappears.
Resetting a boundary on navigation
<ProfilePage key={userId} />
Going from one profile to another inside a transition keeps the old profile
visible while the new one loads, which is usually wrong: the user asked for a
different person. A key tells React this is different content, so
the boundary resets and shows its fallback. The key can sit on the boundary or
any component above it.
Streaming SSR
On the server, Suspense boundaries are the unit of streaming. The server sends the shell immediately with fallbacks in place, then streams each boundary's HTML as its data resolves. The user sees the page structure straight away instead of waiting for the slowest query.
<Layout>
<Suspense fallback={<FeedSkeleton />}>
<Feed /> // slow query — streams in later, does not block the shell
</Suspense>
</Layout>
Selective hydration follows the same boundaries: React hydrates the parts the user interacts with first, so a click on a ready section works even while another is still arriving.
Boundaries also contain server errors. If a component throws while rendering on the server, React does not abort the page. It puts the nearest boundary's fallback into the HTML and tries that component again on the client. One failing widget costs you a spinner, not a 500 — and if the client render fails too, the error boundary takes over.
What can suspend
| Suspends | Does not |
|---|---|
lazy() components | A bare fetch in an effect or event handler |
| Framework data APIs, and Server Components | useState loading flags |
| Query libraries in suspense mode | Anything you have not opted in |
use(promise) — see the use hook |
Suspense does not make ordinary fetching declarative on its own. The data source has to participate, which is why "just use Suspense for loading states" does not work in a plain Vite app without a library.
One more caveat: state is not kept for a render that suspended before it ever mounted. When the data arrives React renders that tree from scratch, so a component that suspends on first render cannot rely on state it set before suspending.
Pair it with an error boundary
<ErrorBoundary fallback={<Failed />}>
<Suspense fallback={<Skeleton />}>
<Profile />
</Suspense>
</ErrorBoundary>
Suspense handles "not yet"; an error boundary handles "never". A chunk that fails to download — a deploy mid-session, a flaky connection — throws, and without a boundary that is a blank page.
Recovering is not the same as catching. A lazy component remembers that its import failed, so re-rendering it will not try again. After a deploy the old chunk name may simply no longer exist. The dependable recovery for a failed chunk is a fallback with a button that reloads the page; for failed data, the boundary's reset should also trigger a fresh request, otherwise it retries into the same rejected promise.
Interview answer, one sentence
"Suspense moves the loading state out of the component and into a boundary that owns a region, which is what makes streaming SSR and selective hydration possible. Its most important interaction is with transitions: inside one, React keeps the current content visible instead of replacing it with the fallback — and a key is how you opt back out when the content is genuinely different."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series