Suspense for Loading States
Before Suspense, every component that fetched data needed to manage its own isLoading / error / data state. The result was boilerplate scattered across every leaf component and inconsistent loading UIs. Suspense inverts this: a component simply reads data as if it were already available, and React handles the loading state at a boundary you choose. The fallback UI lives exactly once, at the right level of the tree.
How Suspense Works
<Suspense> renders its fallback while any child component is "suspended". A component suspends by throwing a Promise during render — React catches it, renders the fallback, and re-tries the child once the Promise resolves. You never call throw yourself; the library (React.lazy, React Query, SWR, or RSC) does it on your behalf.
import { Suspense } from 'react'
// While <UserProfile> is suspended (loading data), React renders the fallback.
// Once data is ready, React swaps in <UserProfile> automatically.
function App() {
return (
<Suspense fallback={<p>Loading profile…</p>}>
<UserProfile userId={1} />
</Suspense>
)
}With React.lazy
React.lazy is the most widely used integration today. The lazy component suspends until its JavaScript chunk has been downloaded and parsed:
import { lazy, Suspense } from 'react'
const HeavyEditor = lazy(() => import('./HeavyEditor'))
function WritingPage() {
return (
<div>
<h1>New Post</h1>
{/* Suspense fallback displays while HeavyEditor.js loads */}
<Suspense fallback={<p>Loading editor…</p>}>
<HeavyEditor />
</Suspense>
</div>
)
}With Data Fetching — React Query Suspense Mode
TanStack Query (React Query) v5 supports Suspense natively via useSuspenseQuery. The component suspenses while the query is loading — no isLoading check needed:
import { Suspense } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
// This component NEVER renders in a loading state —
// it only renders when data is available
function UserCard({ userId }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
})
// 'user' is always defined here — no null checks needed
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
// The loading state is handled one level up, in the boundary
function ProfilePage({ userId }) {
return (
<Suspense fallback={<UserCardSkeleton />}>
<UserCard userId={userId} />
</Suspense>
)
}Nesting Suspense Boundaries
You can nest <Suspense> boundaries to create granular loading states. An outer boundary shows a coarse fallback; inner boundaries show fine-grained spinners for specific sections. Each boundary catches only the Promises thrown by its direct subtree:
import { Suspense } from 'react'
function Dashboard() {
return (
// Outer boundary: entire dashboard fallback
<Suspense fallback={<DashboardSkeleton />}>
<DashboardHeader />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
{/* Each panel has its own boundary — they load independently */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<UserGrowthChart />
</Suspense>
</div>
<Suspense fallback={<TableSkeleton />}>
<RecentOrdersTable />
</Suspense>
</Suspense>
)
}
// RevenueChart and UserGrowthChart load in parallel.
// If RevenueChart is slow, UserGrowthChart still appears as soon as it's ready.Suspense vs Manual isLoading State
To understand what Suspense saves you, compare the same component written both ways:
// ─── Without Suspense ────────────────────────────────────────────
function PostList() {
const { data, isLoading, isError } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
if (isLoading) return <p>Loading…</p> // ← scattered loading state
if (isError) return <p>Error loading posts</p> // ← scattered error state
return (
<ul>
{data.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
)
}
// ─── With Suspense ────────────────────────────────────────────────
function PostList() {
const { data } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
})
// data is always defined — no guards needed
return (
<ul>
{data.map(p => <li key={p.id}>{p.title}</li>)}
</ul>
)
}
// Loading and error states live in ONE place outside:
<ErrorBoundary fallback={<p>Error loading posts</p>}>
<Suspense fallback={<p>Loading…</p>}>
<PostList />
</Suspense>
</ErrorBoundary>Error Handling with ErrorBoundary
Suspense handles the loading case; ErrorBoundary handles the failure case. They complement each other — always pair them when fetching data:
import { Suspense, Component } from 'react'
class ErrorBoundary extends Component {
state = { error: null }
static getDerivedStateFromError(error) {
return { error }
}
render() {
if (this.state.error) {
return (
<div>
<p>Something went wrong: {this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>Retry</button>
</div>
)
}
return this.props.children
}
}
function SafeProfile({ userId }) {
return (
<ErrorBoundary> {/* catches fetch errors */}
<Suspense fallback={<Spinner />}> {/* shows while loading */}
<UserProfile userId={userId} />
</Suspense>
</ErrorBoundary>
)
}Suspense with React Server Components (RSC)
In Next.js App Router, Server Components can await data directly. React streams the response to the client and fills in each Suspense boundary as its data becomes ready — without any client-side JavaScript for the fetch itself:
// app/dashboard/page.tsx — Next.js App Router
import { Suspense } from 'react'
// async Server Component — fetches data on the server
async function RevenueStats() {
const stats = await getRevenueStats() // direct DB/API call, no useEffect
return <StatsCard data={stats} />
}
async function RecentOrders() {
const orders = await getRecentOrders()
return <OrdersTable data={orders} />
}
// This page streams: header appears immediately,
// then each section fills in as its server fetch completes
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<RevenueStats />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</main>
)
}<Suspense fallback={...}>renders the fallback while children are suspended.Works with
React.lazy(code splitting) today, and data fetching via React Query/SWR/RSC.Nest boundaries for granular, independent loading states — each section loads as soon as its own data arrives.
Always pair with
ErrorBoundary— Suspense handles loading, ErrorBoundary handles errors.In Next.js App Router, streaming + Suspense means the shell HTML arrives immediately and sections fill in progressively.