1. đ Introduction
Suspense is one of React's most powerful â and least understood â features. It lets a component "pause" rendering while it waits for something, showing a fallback UI in the meantime. Paired with React.lazy, it enables code splitting: downloading only the JS a user actually needs, exactly when they need it. This tutorial covers Suspense's mechanics, lazy-loaded components and routes, data fetching, and how it fits into React's broader concurrent rendering model.
Information
2. â What is Suspense?
<Suspense> is a component that lets you display a fallback UI while its children aren't yet ready to render â whether that's because their code hasn't loaded yet, or their data hasn't arrived.
SuspenseBasic.jsx
import { Suspense } from "react";
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<ProfilePage />
</Suspense>
);
}3. đ¤ Why Use Suspense?
- Avoids manually tracking a loading boolean in every component that needs to wait for something
- Enables code splitting, shrinking the initial JS bundle users must download
- Provides a single, consistent place to show loading UI for an entire subtree, rather than scattering spinners everywhere
- Composes naturally with Error Boundaries for a complete loading/error/success story
4. âī¸ How Suspense Works
Under the hood, a component "suspends" by throwing a Promise during rendering. React catches that Promise, pauses the subtree, shows the nearest <Suspense> boundary's fallback, and retries rendering once the Promise resolves.
Note
5. đ§ Suspense Boundaries
A Suspense boundary is just the nearest <Suspense> ancestor of a suspending component. Placement matters: a boundary wrapped tightly around one component isolates its loading state, while a boundary higher up covers a whole section at once.
6. đ¨ Fallback UI
The fallback prop can be any JSX â a spinner, a skeleton screen, or simple text â shown for as long as the boundary's children remain suspended.
FallbackUI.jsx
<Suspense fallback={<SkeletonCard />}>
<ProductDetails productId={id} />
</Suspense>Tip
7. đĒ Nested Suspense
Suspense boundaries can be nested. An inner boundary catches suspensions from its own children first; only if there's no inner boundary does the suspension propagate up to an outer one.
NestedSuspense.jsx
<Suspense fallback={<PageSkeleton />}>
<Header />
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
<MainContent />
</Suspense>8. đ§Š Multiple Suspense Boundaries
Using several independent boundaries lets different parts of a page load and reveal themselves separately, rather than the whole page waiting on the single slowest piece.
MultipleBoundaries.jsx
function Dashboard() {
return (
<div className="grid">
<Suspense fallback={<CardSkeleton />}>
<RevenueCard />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<UsersCard />
</Suspense>
<Suspense fallback={<CardSkeleton />}>
<ActivityFeed />
</Suspense>
</div>
);
}Best Practice
9. đĻĨ React.lazy
React.lazy wraps a dynamic import() call so a component's code is only downloaded the first time it's actually rendered.
ReactLazy.jsx
const SettingsPanel = React.lazy(() => import("./SettingsPanel"));Important
10. đĻ Dynamic Imports
A dynamic import() returns a Promise that resolves to a module, and is the underlying browser/bundler feature that both React.lazy and manual code splitting rely on.
DynamicImport.jsx
async function loadChartLibrary() {
const { Chart } = await import("./chartLibrary");
return Chart;
}11. đ§Š Lazy Loading Components
Any component â not just entire pages â can be lazy-loaded, which is especially useful for heavy, conditionally-rendered UI like modals, rich editors, or charting widgets.
LazyComponent.jsx
const ImageEditor = React.lazy(() => import("./ImageEditor"));
function Gallery() {
const [editing, setEditing] = useState(false);
return (
<>
<button onClick={() => setEditing(true)}>Edit image</button>
{editing && (
<Suspense fallback={<Spinner />}>
<ImageEditor />
</Suspense>
)}
</>
);
}12. đŖī¸ Lazy Loading Routes
Combining React.lazy with React Router lets each page's code download only when the user actually navigates there, rather than bundling every page into the initial load.
LazyRoutes.jsx
const Dashboard = React.lazy(() => import("./pages/Dashboard"));
const Settings = React.lazy(() => import("./pages/Settings"));
<Routes>
<Route
path="/dashboard"
element={
<Suspense fallback={<PageSpinner />}>
<Dashboard />
</Suspense>
}
/>
<Route
path="/settings"
element={
<Suspense fallback={<PageSpinner />}>
<Settings />
</Suspense>
}
/>
</Routes>13. âī¸ Code Splitting
Code splitting is the broader strategy: breaking one large JS bundle into smaller chunks that load independently. React.lazy plus Suspense is React's built-in mechanism for achieving this at the component level.
14. đ¤ī¸ Route-Based Code Splitting
Splitting by route is the most common and highest-impact strategy â each page tends to be a natural, self-contained chunk that users only need when they visit it (see Section 12's example).
15. đ§ą Component-Based Code Splitting
Splitting by component targets specific heavy pieces within a page â like a rich text editor, a map, or a video player â that many visitors never actually trigger.
ComponentSplitting.jsx
const MapView = React.lazy(() => import("./MapView"));
function LocationPicker({ showMap }) {
return showMap ? (
<Suspense fallback={<MapSkeleton />}>
<MapView />
</Suspense>
) : (
<AddressForm />
);
}16. đĨ Data Fetching with Suspense
Beyond code, Suspense can also coordinate data loading â a component "suspends" until its data resolves, letting you delete manual loading-state boilerplate. This requires a Suspense-compatible data source, such as certain configurations of TanStack Query or frameworks like Next.js and Relay.
SuspenseDataFetching.jsx
function ProductDetails({ productId }) {
// useSuspenseQuery "throws" internally until data resolves
const { data: product } = useSuspenseQuery({
queryKey: ["product", productId],
queryFn: () => fetchProduct(productId),
});
return <h1>{product.name}</h1>;
}
<Suspense fallback={<Spinner />}>
<ProductDetails productId={id} />
</Suspense>Caution
17. đ Streaming with Suspense
Server-rendering frameworks (like Next.js) can stream HTML to the browser progressively â sending the shell immediately and streaming in each Suspense boundary's content as its data becomes ready on the server, rather than waiting for everything before responding.
18. đ¨ Error Handling with Suspense
Suspense handles the loading case, but not errors â a rejected Promise (a failed fetch, for instance) must be caught by a nearby Error Boundary, not by Suspense itself.
SuspenseWithErrorBoundary.jsx
<ErrorBoundary fallback={<p>Failed to load product.</p>}>
<Suspense fallback={<Spinner />}>
<ProductDetails productId={id} />
</Suspense>
</ErrorBoundary>19. âī¸ Suspense and Concurrent Rendering
Suspense works hand-in-hand with React's concurrent rendering features. useTransition and startTransition let you navigate to a new suspending view while keeping the old content visible (and interactive) until the new content is ready, rather than immediately flashing a fallback.
TransitionWithSuspense.jsx
function Tabs() {
const [tab, setTab] = useState("posts");
const [isPending, startTransition] = useTransition();
const selectTab = (nextTab) => {
startTransition(() => setTab(nextTab));
};
return (
<>
<button onClick={() => selectTab("posts")}>Posts</button>
<button onClick={() => selectTab("comments")}>Comments</button>
<Suspense fallback={<Spinner />}>
{tab === "posts" ? <Posts /> : <Comments />}
</Suspense>
{isPending && <span>Updating...</span>}
</>
);
}20. đ Suspense Best Practices
- Place boundaries deliberately â wrap independent sections separately so one slow piece doesn't block the rest
- Always pair Suspense with an Error Boundary nearby
- Use skeleton fallbacks that approximate real content's shape to reduce layout shift
- Reserve data-fetching Suspense for libraries/frameworks explicitly built to support it
21. ⥠Performance Optimization
Lazy loading trades a slightly later render for a smaller initial bundle â the net win depends on how large the deferred code is and how often users actually need it. Route-based splitting (Section 14) usually offers the best return with the least complexity.
Tip
22. đˇ TypeScript with Suspense
Typing works the same for lazy components as any other â React.lazy preserves the wrapped component's prop types automatically.
TypedLazy.tsx
interface SettingsPanelProps {
userId: string;
}
const SettingsPanel = React.lazy(() => import("./SettingsPanel")) as React.ComponentType<SettingsPanelProps>;23. â ī¸ Common Mistakes
- Forgetting to wrap a lazy component in <Suspense>, causing an error when its code hasn't loaded yet
- Assuming Suspense catches errors â it only handles the loading state, not rejections
- Using React.lazy with a named export without remapping it to a default export first
- Wrapping the entire app in one giant Suspense boundary, so any single slow piece blanks the whole page
- Trying to make plain useEffect-based fetches "work" with Suspense without a compatible data library
Danger
24. đŦ Frequently Asked Questions
Does Suspense replace loading state entirely?
For code loading, yes. For data loading, only when paired with a Suspense-compatible library â plain fetch calls in useEffect still need manual loading state.
Can I nest a Suspense boundary inside another one?
Yes â nested boundaries are a common and encouraged pattern, letting inner content show its own fallback independently of the outer page shell.
Is Suspense only useful for code splitting?
No â while code splitting via React.lazy is the most universally supported use case, Suspense's design also supports coordinated data loading and streaming server rendering in frameworks built to take advantage of it.