Suspense Boundary: Declarative Loading States with React
How to use React Suspense boundaries for declarative loading states. Covers data fetching, streaming SSR, nested boundaries, and error boundaries.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
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.
FAQ
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
How to ship interactivity only where needed using islands architecture. Covers Astro islands, partial hydration, React islands, and performance benefits.