React Suspense: loading fallbacks, boundaries and use()
Understand React Suspense loading fallbacks, nested boundaries and React 19 use(), including why fetching in an Effect does not activate Suspense.
On this page
React Suspense shows a fallback while a child suspends during rendering. It works with lazy, supported data sources and React 19's use() when reading a promise. Fetching data in an Effect or an event handler does not activate Suspense.
The React Suspense documentation describes which data sources it supports.
What Actually Happens
When React renders a component tree and encounters a component that isn't ready yet, it needs somewhere to go. The mechanism it uses is a thrown promise.
A component signals that it's loading by throwing a promise. React catches it, walks up the tree to find the nearest Suspense boundary, renders the fallback from that boundary, and subscribes to the promise. When the promise resolves, React retries the render from the suspended component down.
// This is what happens under the hood when a component is "suspended"
function SomeSuspendingComponent() {
const data = cache.read() // throws a promise if not ready
return <div>{data.title}</div>
}
The cache.read() example illustrates the loading signal, not an API to build around. Use a framework or library with Suspense support, or React's use() API. React does not document a supported interface for writing a custom Suspense data source.
The use() Hook
React 19 introduced use(), which lets you unwrap a promise inside a component directly. If the promise isn't resolved yet, React suspends the component automatically.
import { use, Suspense } from 'react'
function UserProfile({ userPromise }: { userPromise: Promise<{ name: string }> }) {
const user = use(userPromise) // suspends until resolved
return <p>{user.name}</p>
}
// Receive a stable promise from the framework or parent.
export default function Page({ userPromise }: { userPromise: Promise<{ name: string }> }) {
return (
<Suspense fallback={<p>Loading user...</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
)
}
The parent supplies a stable promise. Creating it during this component's render would start a new request on each retry. In a framework, create or cache the promise using its supported data-loading mechanism.
The boundary shows the fallback until the promise resolves. See React Server Components and Client Components for where that work can happen.
How Boundaries Nest
You can have multiple Suspense boundaries at different levels of the tree. React uses the nearest ancestor boundary to catch a thrown promise. Inner boundaries resolve independently, so parts of the UI can finish loading without waiting for each other.
export default function Dashboard() {
return (
<div>
<Suspense fallback={<p>Loading stats...</p>}>
<StatsPanel />
</Suspense>
<Suspense fallback={<p>Loading feed...</p>}>
<ActivityFeed />
</Suspense>
</div>
)
}
StatsPanel and ActivityFeed suspend independently. If ActivityFeed takes longer, it stays in its fallback state while StatsPanel has already shown its content. No global loading state needed.
Suspense with React Query
React Query supports Suspense mode via useSuspenseQuery. Instead of managing isLoading manually, the hook throws a promise when data isn't available. The component only ever sees resolved data.
import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense } from 'react'
function PostList() {
const { data } = useSuspenseQuery({
queryKey: ['posts'],
queryFn: async () => {
const r = await fetch('/api/posts')
if (!r.ok) {
throw new Error(`Failed to fetch posts: ${r.status}`)
}
return r.json()
},
})
return (
<ul>
{data.map((post: { id: string; title: string }) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export default function BlogPage() {
return (
<Suspense fallback={<p>Loading posts...</p>}>
<PostList />
</Suspense>
)
}
No if (isLoading) return <Spinner /> inside PostList. The component is clean and always works with data.
Suspense for Lazy Loading
The other common use for Suspense is code splitting with React.lazy. The pattern is the same. The lazy component throws a promise when the chunk hasn't loaded yet, and the boundary shows the fallback.
import { lazy, Suspense } from 'react'
const HeavyChart = lazy(() => import('./HeavyChart'))
export function AnalyticsPage() {
return (
<Suspense fallback={<p>Loading chart...</p>}>
<HeavyChart />
</Suspense>
)
}
The HeavyChart bundle is only fetched when the component is first rendered. Everything above it renders immediately.
Combining Suspense with Error Boundaries
Suspense handles the loading state. It doesn't handle errors. A rejected promise that goes uncaught will bubble up and crash the component tree. That's where ErrorBoundary comes in.
The pattern is to wrap Suspense in an ErrorBoundary so that network errors, bad responses, and thrown exceptions are handled gracefully alongside the loading state.
import { Component, type ReactNode, Suspense } from 'react'
type ErrorBoundaryProps = {
fallback: ReactNode
children: ReactNode
}
type ErrorBoundaryState = { hasError: boolean }
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false }
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true }
}
render() {
if (this.state.hasError) {
return this.props.fallback
}
return this.props.children
}
}
export function AsyncSection({ children }: { children: ReactNode }) {
return (
<ErrorBoundary fallback={<p>Something went wrong.</p>}>
<Suspense fallback={<p>Loading...</p>}>
{children}
</Suspense>
</ErrorBoundary>
)
}
With this wrapper, any async section of the UI has both states covered. Loading shows the Suspense fallback. Errors show the ErrorBoundary fallback. The happy path renders the component with its data.
Choosing boundary placement
Think of Suspense as a try/catch for rendering. The component tree below the boundary might throw a promise (I'm loading) or an error (something broke). Suspense catches the loading signal. ErrorBoundary catches the error. Together they give you a complete strategy for async UI without scattering loading and error state across every component.
Keep boundaries at a granularity that makes sense for the user experience. Too coarse and the whole page spins. Too fine and you get a jarring cascade of tiny spinners. Match the boundary to the content unit: a panel, a feed, a modal, not a whole page or an individual row.
React error boundaries in practice covers the failure state alongside these loading boundaries.
Get notified when I ship something new.
A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.