React Suspense and Concurrent Features Guide (2026)
React's concurrent mode fundamentally changes how React schedules rendering. Instead of blocking the main thread until an entire component tree is ready, React can pause work, switch to higher-priority updates, and resume where it left off. Suspense is the user-facing API for this — it lets you declaratively specify loading states while React handles the async coordination behind the scenes.
Table of Contents
Suspense Basics
A <Suspense> boundary catches components that are "suspended" (waiting for something async) and shows a fallback until they're ready:
import { Suspense } from 'react'
import { ProductList } from './ProductList'
function App() {
return (
<Suspense fallback={<p>Loading products...</p>}>
<ProductList />
</Suspense>
)
}
Key rules for Suspense boundaries:
- Place them as close to the suspending component as possible for fine-grained loading states
- Multiple siblings can suspend independently inside the same boundary
- Suspense boundaries interact with Error Boundaries — wrap both for production resilience
Code Splitting with React.lazy
React.lazy defers loading a component's code until it's first rendered:
import { lazy, Suspense } from 'react'
// Bundle split — HeavyChart code loads only when rendered
const HeavyChart = lazy(() => import('./HeavyChart'))
function Dashboard() {
return (
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart data={data} />
</Suspense>
)
}
// Named export pattern
const Modal = lazy(() =>
import('./Modal').then(m => ({ default: m.Modal }))
)
Data Fetching with Suspense
TanStack Query and SWR both support Suspense mode — the component suspends while data loads:
// TanStack Query suspense mode
import { useSuspenseQuery } from '@tanstack/react-query'
function UserProfile({ userId }) {
// Suspends until data is available — no loading/error state needed here
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
return <div>{user.name}</div>
}
// Wrap in Suspense + ErrorBoundary in the parent
function Page() {
return (
<ErrorBoundary fallback={<p>Error loading user</p>}>
<Suspense fallback={<Skeleton />}>
<UserProfile userId="123" />
</Suspense>
</ErrorBoundary>
)
}
The use() Hook
React 19 introduced the use() hook — a universal hook for reading async values. Unlike hooks, use() can be called conditionally:
import { use, Suspense } from 'react'
// use() unwraps a Promise — suspends until resolved
function Message({ messagePromise }) {
const message = use(messagePromise)
return <p>{message}</p>
}
// Create the promise outside (not inside) the component
function Page() {
const messagePromise = fetchMessage()
return (
<Suspense fallback={<p>Loading...</p>}>
<Message messagePromise={messagePromise} />
</Suspense>
)
}
// use() also reads Context
import { ThemeContext } from './ThemeContext'
function Button() {
const theme = use(ThemeContext)
return <button className={theme}>Click</button>
}
useTransition and startTransition
Transitions mark state updates as non-urgent, letting React interrupt them if a higher-priority update arrives (like user typing):
'use client'
import { useState, useTransition, Suspense } from 'react'
function SearchPage() {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
function handleSearch(e) {
const value = e.target.value
setQuery(value)
// Mark the navigation/results update as a transition
startTransition(() => {
setQuery(value)
})
}
return (
<div>
<input onChange={handleSearch} />
{/* isPending shows stale UI with a loading indicator */}
<div style={{ opacity: isPending ? 0.7 : 1 }}>
<Suspense fallback={<Spinner />}>
<SearchResults query={query} />
</Suspense>
</div>
</div>
)
}
useDeferredValue
useDeferredValue defers re-rendering an expensive component until the browser is idle:
'use client'
import { useState, useDeferredValue } from 'react'
function FilterableList({ items }) {
const [filter, setFilter] = useState('')
// deferredFilter lags behind filter — expensive list re-render is deferred
const deferredFilter = useDeferredValue(filter)
return (
<div>
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder="Filter..."
/>
{/* This re-renders with deferred value — input stays responsive */}
<ExpensiveList filter={deferredFilter} />
</div>
)
}
// Memoize to only re-render when deferredFilter changes
const ExpensiveList = memo(function({ filter }) {
const filtered = items.filter(i => i.includes(filter))
return <ul>{filtered.map(i => <li key={i}>{i}</li>)}</ul>
})
Streaming SSR
Next.js App Router uses React's streaming SSR automatically. Each Suspense boundary is a streaming chunk:
// app/page.tsx — streams in 3 independent chunks
export default function HomePage() {
return (
<main>
{/* Chunk 1: renders immediately */}
<Hero />
{/* Chunk 2: streams when user data ready */}
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
{/* Chunk 3: streams when recommendations ready */}
<Suspense fallback={<CardSkeleton count={3} />}>
<Recommendations />
</Suspense>
</main>
)
}
Practical Patterns
Nested Suspense for progressive disclosure:
<Suspense fallback={<PageSkeleton />}>
<PageHeader />
<Suspense fallback={<ContentSkeleton />}>
<MainContent />
<Suspense fallback={<CommentSkeleton />}>
<Comments />
</Suspense>
</Suspense>
</Suspense>
Avoid Suspense waterfalls — fetch in parallel, not in sequence:
// ❌ Waterfall: UserProfile fetches, then Comments fetches after
<UserProfile /> {/* suspends, then resolves */}
<Comments /> {/* starts fetching only after UserProfile done */}
// ✅ Parallel: both fetch simultaneously
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<CommentSkeleton />}>
<Comments />
</Suspense>