- Published on
React Suspense and Data Fetching
- Authors

- Name
- Alex Peng
- @aJinTonic
Suspense was introduced in React 16.6 for lazy-loading components. Since then, the story around using it for data fetching has evolved significantly, from Concurrent Mode experiments to stable features in React 18 and the patterns that frameworks like Next.js and TanStack Query have built on top of it.
Here's what Suspense actually is and how data fetching integrates with it.
The Core Idea
Suspense lets you declaratively specify a loading state for any part of your component tree. Instead of scattering if (isLoading) return <Spinner /> checks throughout your components, you wrap a subtree in <Suspense> and specify a fallback:
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile userId={42} />
</Suspense>
)
}
While UserProfile (or anything inside it) is "suspended", React renders the fallback instead.
How a Component Suspends
A component suspends by throwing a Promise. This is the mechanism under the hood:
// Simplified, how Suspense-compatible data sources work
let data: User | undefined
let promise: Promise<void> | undefined
function readUser(id: number): User {
if (data) return data
if (!promise) {
promise = fetchUser(id).then(result => { data = result })
}
throw promise // ← this is how the component "suspends"
}
When React catches a thrown Promise, it:
- Renders the nearest
<Suspense>fallback instead - Subscribes to the Promise
- When the Promise resolves, re-renders the subtree
This means the component body assumes the data is available, no loading checks, no undefined handling:
function UserProfile({ userId }: { userId: number }) {
const user = readUser(userId) // might throw a Promise
return <div>{user.name}</div> // if we get here, data is available
}
In Practice with TanStack Query
You don't write the throwing-Promises machinery yourself. Libraries handle it. TanStack Query (React Query) has a useSuspenseQuery hook:
import { useSuspenseQuery } from '@tanstack/react-query'
function UserProfile({ userId }: { userId: number }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
})
// data is guaranteed to be defined here
return <div>{user.name}</div>
}
Wrap it in Suspense and an error boundary:
import { ErrorBoundary } from 'react-error-boundary'
function App() {
return (
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Spinner />}>
<UserProfile userId={42} />
</Suspense>
</ErrorBoundary>
)
}
Nested Suspense Boundaries
You can use Suspense at multiple levels of the tree. Each boundary only "catches" suspensions from its direct subtree:
function Dashboard() {
return (
<div>
{/* This can render independently */}
<Suspense fallback={<NavSkeleton />}>
<Nav />
</Suspense>
{/* This can render independently */}
<Suspense fallback={<FeedSkeleton />}>
<Feed />
</Suspense>
</div>
)
}
Nav and Feed load in parallel. Whichever finishes first renders first. This is the main advantage over top-level loading state: parallel loading without coordination code.
Server Components and Suspense
In Next.js App Router, React Server Components are async components that fetch on the server:
// This is a Server Component, async works natively
async function UserProfile({ userId }: { userId: number }) {
const user = await fetchUser(userId) // direct async/await
return <div>{user.name}</div>
}
You still wrap these in <Suspense> to stream the component to the client as it resolves:
function Page({ params }: { params: { id: string } }) {
return (
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={Number(params.id)} />
</Suspense>
)
}
This is probably where you'll use Suspense most in a Next.js app, wrapping slow server components so the page renders progressively rather than waiting for the slowest component.
When to Use Suspense
Suspense for data fetching makes sense when:
- You want parallel loading of independent subtrees without coordination code
- You're using a framework or library that supports it (Next.js, TanStack Query)
- You want the component to assume data is available, simplifying the component body
It's less useful if you need fine-grained control over loading states within a single component, or if you're not using a library that handles the Promise-throwing mechanism.
The direction React and the ecosystem is heading is clearly towards Suspense as the standard loading pattern. Understanding how it works under the hood makes you a more effective user of the frameworks built on top of it.