Overview
React Suspense lets you declaratively specify loading states without writing conditional rendering logic. Wrap a component in a <Suspense> boundary with a fallback prop, and React shows the fallback while the component’s data is loading. This eliminates the if (loading) return <Spinner /> pattern scattered throughout components. Suspense works with lazy-loaded components, data fetching libraries (React Query, SWR, Relay), and server-side streaming. Combined with error boundaries, it provides a complete declarative approach to async UI states.
When to Use
- Code-splitting with
React.lazy()for route-level or component-level splitting - Data fetching with Suspense-enabled libraries (React Query v5, Relay, SWR)
- Streaming SSR with selective hydration — send HTML as data becomes available
- Nested loading states where different parts of the page load independently
- Eliminating manual loading state management across many components
When NOT to Use
- Simple applications without code-splitting or data fetching
- When you need fine-grained control over loading transitions — Suspense is all-or-nothing per boundary
- Legacy React versions (< 18) without concurrent features
- Cases where loading states must show partial data (Suspense shows fallback, not partial content)
Solution
Basic Suspense with lazy loading
// App.jsx — lazy load a heavy component
import React, { Suspense, lazy } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
const AdminPanel = lazy(() => import('./AdminPanel'));
function App() {
return (
<div>
<Header />
<Suspense fallback={<div className="loading">Loading chart...</div>}>
<HeavyChart data={chartData} />
</Suspense>
<Suspense fallback={<div className="loading">Loading admin panel...</div>}>
<AdminPanel />
</Suspense>
</div>
);
}
Suspense with data fetching
// useSuspenseFetch.js — data fetching hook that integrates with Suspense
import { use, useState, useEffect } from 'react';
function fetchUser(userId) {
return fetch(`/api/users/${userId}`).then(res => res.json());
}
// React 19: use() hook suspends until promise resolves
function UserProfile({ userId }) {
const user = use(fetchUser(userId));
return (
<div className="profile">
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// Parent wraps in Suspense
function App() {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={42} />
</Suspense>
);
}
React Query with Suspense
// useUser.js — React Query v5 with suspense
import { useSuspenseQuery } from '@tanstack/react-query';
function useUser(userId) {
return useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
}
// UserCard.jsx — no loading state needed
function UserCard({ userId }) {
const { data: user } = useUser(userId);
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.role}</p>
</div>
);
}
// Dashboard.jsx — Suspense boundary at the top
function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<div className="dashboard">
<UserCard userId={42} />
<UserStats userId={42} />
<UserActivity userId={42} />
</div>
</Suspense>
);
}
Nested Suspense boundaries
// NestedBoundaries.jsx — granular loading states
function Page() {
return (
<div className="page">
<Header />
{/* Main content loads first */}
<Suspense fallback={<MainSkeleton />}>
<MainContent />
</Suspense>
{/* Sidebar loads independently */}
<aside>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</aside>
{/* Comments load last — don't block the page */}
<section className="comments">
<Suspense fallback={<div>Loading comments...</div>}>
<Comments postId={1} />
</Suspense>
</section>
</div>
);
}
Suspense with error boundaries
// ErrorBoundary.jsx — catch errors from suspended components
import React from 'react';
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error('Component error:', error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className="error-boundary">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false })}>
Try again
</button>
</div>
)
);
}
return this.props.children;
}
}
// Usage — wrap Suspense in ErrorBoundary
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<LoadingSpinner />}>
<AsyncComponent />
</Suspense>
</ErrorBoundary>
);
}
Streaming SSR with Suspense
// server.js — streaming SSR with selective hydration
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';
function handler(req, res) {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapModules: ['/client.js'],
onShellReady() {
res.setHeader('content-type', 'text/html');
pipe(res);
},
onError(error) {
console.error('SSR error:', error);
},
});
}
// App.jsx — Suspense boundaries on the server
function App() {
return (
<html>
<body>
<div id="root">
<Header />
{/* Critical content renders immediately */}
<Suspense fallback={<ProductSkeleton />}>
<Product productId={1} />
</Suspense>
{/* Non-critical content streams later */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews productId={1} />
</Suspense>
</div>
</body>
</html>
);
}
Suspense list for coordinated loading
// SuspenseList.jsx — coordinate multiple Suspense boundaries
import { Suspense, SuspenseList } from 'react';
function Article({ id }) {
const article = use(fetchArticle(id));
return <article>{article.content}</article>;
}
function Comments({ id }) {
const comments = use(fetchComments(id));
return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
function RelatedArticles({ id }) {
const related = use(fetchRelated(id));
return <div>{related.map(a => <ArticleCard key={a.id} {...a} />)}</div>;
}
function ArticlePage({ articleId }) {
return (
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<ArticleSkeleton />}>
<Article id={articleId} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments id={articleId} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedArticles id={articleId} />
</Suspense>
</SuspenseList>
);
}
Use transition with Suspense
// TransitionWithSuspense.jsx — useTransition for smooth state changes
import { useTransition, Suspense } from 'react';
function TabContainer() {
const [tab, setTab] = useState('overview');
const [isPending, startTransition] = useTransition();
const handleTabChange = (newTab) => {
startTransition(() => {
setTab(newTab);
});
};
return (
<div className="tabs">
<nav>
<button
onClick={() => handleTabChange('overview')}
disabled={isPending}
>
Overview
</button>
<button
onClick={() => handleTabChange('analytics')}
disabled={isPending}
>
Analytics
</button>
</nav>
<Suspense fallback={<TabSkeleton />}>
{tab === 'overview' && <OverviewTab />}
{tab === 'analytics' && <AnalyticsTab />}
</Suspense>
</div>
);
}
Skeleton fallbacks
// Skeletons.jsx — skeleton components for fallbacks
function UserCardSkeleton() {
return (
<div className="user-card skeleton">
<div className="skeleton-avatar" />
<div className="skeleton-line w-60" />
<div className="skeleton-line w-40" />
</div>
);
}
function DashboardSkeleton() {
return (
<div className="dashboard skeleton">
<div className="skeleton-header" />
<div className="skeleton-grid">
<div className="skeleton-card" />
<div className="skeleton-card" />
<div className="skeleton-card" />
</div>
</div>
);
}
function TableSkeleton({ rows = 5 }) {
return (
<div className="table-skeleton">
<div className="skeleton-row skeleton-header-row" />
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="skeleton-row">
<div className="skeleton-cell" />
<div className="skeleton-cell" />
<div className="skeleton-cell" />
</div>
))}
</div>
);
}
Variants
Suspense with cached promises
// cache.js — cache promises to avoid re-fetching
const cache = new Map();
function fetchWithCache(url) {
if (!cache.has(url)) {
cache.set(url, fetch(url).then(res => res.json()));
}
return cache.get(url);
}
// Component uses cached promise with use()
function UserProfile({ userId }) {
const user = use(fetchWithCache(`/api/users/${userId}`));
return <h1>{user.name}</h1>;
}
Conditional Suspense boundary
// ConditionalSuspense.jsx — wrap in Suspense only when needed
function MaybeLazy({ shouldLazy, component: Component, ...props }) {
if (!shouldLazy) {
return <Component {...props} />;
}
return (
<Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</Suspense>
);
}
// Usage
const HeavyComponent = lazy(() => import('./HeavyComponent'));
const LightComponent = () => <div>Light</div>;
function App({ isPremium }) {
return (
<MaybeLazy
shouldLazy={isPremium}
component={isPremium ? HeavyComponent : LightComponent}
/>
);
}
Best Practices
-
For a deeper guide, see Container-Presenter: Separate Data Logic from Rendering.
-
Place boundaries strategically — wrap the smallest unit that should independently show a loading state
-
Use nested boundaries — don’t use one giant boundary for the whole page; let different sections load independently
-
Provide meaningful fallbacks — skeleton screens that match the content shape, not just “Loading…”
-
Combine with error boundaries — Suspense handles loading, ErrorBoundary handles failures
-
Use
useTransitionfor user-triggered suspends — keeps the current UI visible during the transition -
Don’t suspend in event handlers without transitions — wrap in
startTransitionto avoid blocking -
Cache promises — re-suspending on every render causes infinite loops if the promise isn’t cached
-
Stream critical content first — in SSR, put critical content outside Suspense and non-critical inside
Common Mistakes
- One boundary for everything: wrapping the entire app in one Suspense. A single slow component blocks the whole page. Use nested boundaries.
- Uncached promises: calling
fetch()directly in render without caching. Each render creates a new promise, causing infinite re-suspension. - Fallback doesn’t match content: showing a spinner where a table will appear causes layout shift. Use skeleton screens that match the content shape.
- No error boundary: Suspense only handles loading. Without an error boundary, a rejected promise crashes the app.
- Suspending in event handlers: calling a suspending function in an onClick without
useTransitionblocks the UI.
Frequently Asked Questions
What is React Suspense?
A feature that lets components declaratively wait for async data. Wrap components in <Suspense fallback={...}> and React shows the fallback while data loads — no manual loading state needed.
Does Suspense work with any data fetching library?
No. The library must support Suspense. React Query v5 (useSuspenseQuery), Relay, SWR (with suspense: true), and React 19's use() hook work. Plain fetch() needs a caching wrapper.
What is the difference between Suspense and conditional loading?
With conditional loading, you write if (loading) return <Spinner /> in every component. With Suspense, the component just reads data and React handles the loading state at the boundary. The component code is simpler.
Can I use Suspense for client-side data fetching?
Yes. With React 19's use() hook or React Query's useSuspenseQuery, client-side data fetching suspends automatically. The component reads data as if it's synchronous.
What is streaming SSR?
A server-side rendering mode where React sends HTML in chunks. Critical content arrives first, non-critical content (wrapped in Suspense) streams later. The browser can start rendering before all data is ready.
Related Resources
Container-Presenter: Separate Data Logic from Rendering
How to separate data-fetching logic from rendering in React using the container-presenter pattern. Covers hooks migration, testing benefits, and trade-offs.
PatternOptimistic Update: Update UI Immediately, Reconcile on
How to update UI immediately and reconcile on server response in React. Covers rollback on error, conflict resolution, and React Query integration.
PatternIslands Architecture: Partial Hydration for Faster Pages
Server-render the page as static HTML and hydrate only the interactive islands. Covers Astro client directives, partial hydration, shared state, and when islands beat a SPA.