Suspense & Lazy Loading
Suspense lets a component “wait” for something — a lazily loaded chunk of code or an async data source — and declaratively render a fallback UI while it waits. Instead of scattering isLoading flags across your tree, you wrap a region in a <Suspense> boundary and let React coordinate the loading state. Used well, Suspense shrinks your initial bundle, keeps loading UI consistent, and pairs with concurrent features to avoid jarring spinners and layout shift.
How Suspense works
A <Suspense> boundary watches its subtree for components that suspend. When a child throws a promise (the mechanism React uses internally), the nearest boundary catches it and renders its fallback prop until the promise resolves. Then it swaps the fallback for the real content.
There are two main things that suspend today: components loaded with React.lazy, and data reads from Suspense-enabled sources (the use hook in React 19, or a framework router/data layer).
import { Suspense, lazy } from "react";
const Dashboard = lazy(() => import("./Dashboard.jsx"));
export default function App() {
return (
<Suspense fallback={<p>Loading dashboard…</p>}>
<Dashboard />
</Suspense>
);
}
The import() call splits Dashboard into its own chunk that the browser only fetches when the component first renders. The result is a smaller initial download.
Lazy loading components
React.lazy takes a function that returns a dynamic import() and returns a component you render like any other. It must resolve to a module with a default export.
import { Suspense, lazy } from "react";
const Editor = lazy(() => import("./Editor.jsx"));
const Preview = lazy(() => import("./Preview.jsx"));
function Workspace() {
return (
<div className="workspace">
<Suspense fallback={<Skeleton lines={6} />}>
<Editor />
</Suspense>
<Suspense fallback={<Skeleton lines={3} />}>
<Preview />
</Suspense>
</div>
);
}
Tip: Define
lazy()components at module scope, never inside the body of another component. Creating them during render produces a brand-new component type on every render, which remounts the subtree and refetches the chunk.
Nesting boundaries
You can place boundaries at different depths to control granularity. An outer boundary covers a whole route; inner boundaries reveal sections independently as they become ready. This is how you avoid an “all or nothing” spinner.
function ProfilePage() {
return (
<Suspense fallback={<PageShell />}>
<ProfileHeader />
<Suspense fallback={<CommentsSkeleton />}>
<Comments />
</Suspense>
</Suspense>
);
}
Here the page shell and header appear as soon as they’re ready, while a slower Comments section keeps showing its own skeleton without holding up the rest of the page.
| Pattern | Effect | When to use |
|---|---|---|
| Single top-level boundary | One fallback for the whole view | Simple pages, full-route splits |
| Nested boundaries | Sections reveal independently | Dashboards, feeds, mixed-speed data |
| Boundary per widget | Fine-grained, many fallbacks | Highly modular layouts |
Avoiding layout shift
The most common Suspense mistake is a fallback whose size differs from the real content, causing the page to jump when content arrives. Make fallbacks reserve the same space — use skeletons sized to the eventual layout rather than a centered spinner.
function CardSkeleton() {
return (
<article className="card" aria-hidden="true">
<div className="card__avatar shimmer" />
<div className="card__line shimmer" style={{ width: "70%" }} />
<div className="card__line shimmer" style={{ width: "40%" }} />
</article>
);
}
function UserCard() {
return (
<Suspense fallback={<CardSkeleton />}>
<UserCardContent />
</Suspense>
);
}
Because the skeleton occupies the same box dimensions as UserCardContent, the swap is seamless and the surrounding layout stays put.
Combining with transitions
When new content needs to suspend in response to a user action (clicking a tab, navigating), showing the fallback again can feel like a regression — the UI flickers back to a loading state. Wrap the state update in startTransition (or useTransition) and React will keep the current content on screen while the next view loads in the background.
import { Suspense, useState, useTransition } from "react";
function Tabs() {
const [tab, setTab] = useState("overview");
const [isPending, startTransition] = useTransition();
function select(next) {
startTransition(() => setTab(next));
}
return (
<>
<nav style={{ opacity: isPending ? 0.6 : 1 }}>
<button onClick={() => select("overview")}>Overview</button>
<button onClick={() => select("activity")}>Activity</button>
</nav>
<Suspense fallback={<PanelSkeleton />}>
<Panel tab={tab} />
</Suspense>
</>
);
}
Output:
Click "Activity" → current panel stays visible, nav dims (isPending = true)
Activity chunk/data resolves → panel swaps in, nav returns to full opacity
The first time the boundary mounts it still uses the fallback. On subsequent transitions, the existing content is preserved and only the pending indicator (isPending) signals work in progress — no flash of the skeleton.
Best practices
- Declare
lazy()components at module scope so chunks load once and subtrees don’t remount. - Size fallbacks to match the real content’s dimensions to prevent cumulative layout shift.
- Nest boundaries so fast sections aren’t blocked by slow ones, but don’t over-fragment with a boundary on every element.
- Use
useTransition/startTransitionfor navigations and updates so users keep seeing current content instead of a fallback flash. - Reserve full-screen spinners for the very first load; prefer skeletons for in-place updates.
- Pair
lazywith route-based code splitting for the biggest initial-bundle wins, and preload likely-next chunks on hover or intent. - Always provide a fallback — a Suspense boundary without one will propagate suspension up to the next ancestor boundary.