Suspense has been in React since 16.6, but data fetching with Suspense was experimental until React 18/19. Now it's a first-class async UI primitive, and the new use() hook gives you a direct way to integrate it without a library.
What Suspense Actually Does
Suspense doesn't know anything about fetching. When a component throws a Promise during render, React catches it, renders the nearest <Suspense> boundary's fallback, and retries when the Promise resolves.
// Conceptually, what libraries do:
function fetchUser(id: string) {
if (cache.has(id)) return cache.get(id)
const promise = fetch(`/api/users/${id}`).then(r => r.json())
cache.set(id, promise)
throw promise // ← Suspense catches this
}
The use() Hook
React 19's use() unwraps a Promise (or Context) inline during render, integrating with Suspense automatically:
import { use, Suspense } from 'react'
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // suspends until resolved
return <div><h2>{user.name}</h2><p>{user.email}</p></div>
}
function Page() {
const userPromise = fetchUser('123') // created once, passed down
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
Critical: Create the Promise outside the component. If you create it inside, you get a new Promise on every render — infinite suspense loop:
// ❌ New Promise every render — infinite loop
function UserProfile({ id }: { id: string }) {
const user = use(fetchUser(id))
}
// ✅ Stable Promise reference passed as prop
function Page({ id }: { id: string }) {
const userPromise = useMemo(() => fetchUser(id), [id])
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
use() with Context
use() also works as a context reader — unlike useContext, it can be called inside conditions:
function Button({ disabled }: { disabled?: boolean }) {
if (disabled) return <button disabled>...</button>
const theme = use(ThemeContext) // ✅ conditional call works
return <button className={theme.buttonClass}>...</button>
}
Error Boundaries
When a Promise rejects, you need an Error Boundary (Suspense only handles pending state):
npm install react-error-boundary
import { ErrorBoundary } from 'react-error-boundary'
function FallbackUI({ error, resetErrorBoundary }: FallbackProps) {
return (
<div>
<p>Failed to load: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)
}
// ErrorBoundary MUST be outside Suspense
<ErrorBoundary FallbackComponent={FallbackUI}>
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
</ErrorBoundary>
Nested Suspense: Progressive Loading
Instead of one giant loading state, nest boundaries so each section loads independently:
export default function DashboardPage() {
const statsPromise = fetchStats() // fast
const activityPromise = fetchActivity() // medium
const recsPromise = fetchRecommendations() // slow
return (
<main>
<Suspense fallback={<StatsSkeleton />}>
<StatsSection statsPromise={statsPromise} />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed activityPromise={activityPromise} />
</Suspense>
<Suspense fallback={<RecsSkeleton />}>
<Recommendations recsPromise={recsPromise} />
</Suspense>
</main>
)
}
Users see content progressively as each section resolves — no waiting for the slowest section.
Suspense in Next.js Server Components
Server Components can be async — they await data directly. Suspense coordinates streaming:
// Server Component — no 'use client'
async function StatsSection() {
const stats = await fetchStats()
return <StatsGrid stats={stats} />
}
export default function DashboardPage() {
return (
<main>
{/* HTML streams to the client as each section completes */}
<Suspense fallback={<StatsSkeleton />}>
<StatsSection />
</Suspense>
</main>
)
}
No client-side JavaScript needed, no useEffect, no re-fetching on hydration.
Pass Promises from Server to Client
Kick off fetching on the server, pass the Promise to a Client Component:
// Server Component — starts fetch immediately, doesn't await
export default async function ProductPage({ params }: { params: { id: string } }) {
const reviewsPromise = fetchReviews(params.id) // no await
return (
<div>
<ProductInfo id={params.id} />
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewsSection reviewsPromise={reviewsPromise} />
</Suspense>
</div>
)
}
// Client Component
'use client'
function ReviewsSection({ reviewsPromise }: { reviewsPromise: Promise<Review[]> }) {
const reviews = use(reviewsPromise)
return <ReviewsList reviews={reviews} />
}
The fetch starts on the server before the client hydrates. By the time hydration happens, the Promise may already be resolved.
useTransition + Suspense: Avoid Fallback Flicker
When navigating or updating state, useTransition keeps the old content visible instead of showing the skeleton:
'use client'
function ProductList() {
const [isPending, startTransition] = useTransition()
const [category, setCategory] = useState('all')
function handleChange(newCategory: string) {
startTransition(() => setCategory(newCategory))
}
return (
<div>
<CategoryFilter onChange={handleChange} disabled={isPending} />
{/* No skeleton flash — old content stays visible while new loads */}
<Suspense fallback={<ProductsSkeleton />}>
<Products category={category} />
</Suspense>
</div>
)
}
Without startTransition: change → skeleton flash → new content.
With startTransition: change → old content stays (pending) → new content appears.
Common Mistakes
// ❌ ErrorBoundary inside Suspense — rejections bypass it
<Suspense fallback={<Skeleton />}>
<ErrorBoundary fallback={<Error />}> {/* wrong order */}
// ✅ ErrorBoundary outside Suspense
<ErrorBoundary fallback={<Error />}>
<Suspense fallback={<Skeleton />}>
// ❌ No Suspense boundary around use()
function Page() {
return <UserProfile userPromise={promise} /> // throws: no fallback specified
// ✅
function Page() {
return (
<Suspense fallback={<Skeleton />}>
<UserProfile userPromise={promise} />
</Suspense>
)
}
Quick Reference
// use() for Promises
const data = use(dataPromise)
// use() for Context (works in conditions)
const theme = use(ThemeContext)
// Standard pattern
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
// With error handling
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
</ErrorBoundary>
// Avoid skeleton flash on navigation
startTransition(() => setState(newValue))
// Server Component
async function ServerComp() {
const data = await fetchData()
return <div>{data.title}</div>
}
Suspense and use() answer one question: "is this part of the UI ready to render?" If not, show the fallback. When it is, swap in the real content. The data layer plugs into this mechanism — the coordination logic stays in React.
Full article at stacknotice.com/blog/react-suspense-use-hook-2026
Top comments (0)