Published on

React Suspense and Data Fetching

Authors

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:

  1. Renders the nearest <Suspense> fallback instead
  2. Subscribes to the Promise
  3. 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.