← Back to blogReact

Using React Suspense for data fetching: what works and what doesn't

June 22, 2024·8 min read·1 comment

React Suspense was introduced for code splitting with React.lazy. The vision was always broader: a unified way to handle any asynchronous operation, including data fetching. As of 2024, that broader vision is partially realised.

This is an honest assessment of what works, what doesn't, and how I use Suspense in production.

What Suspense actually does

Suspense lets you declaratively specify a loading state for a section of your component tree:

<Suspense fallback={<Spinner />}>
  <UserProfile userId="123" />
</Suspense>

When UserProfile "suspends" (throws a promise during render), React catches it, renders the fallback, and retries the component when the promise resolves. The component itself doesn't manage loading state. It either has the data and renders, or it suspends.

The mechanism is a thrown promise. When a component needs data that isn't available yet, it throws a promise. React catches the promise and shows the nearest Suspense boundary's fallback until the promise resolves.

Where it works well: code splitting

The most mature use of Suspense is with React.lazy for code splitting:

const AdminPanel = lazy(() => import('./AdminPanel'))

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/admin" element={<AdminPanel />} />
      </Routes>
    </Suspense>
  )
}

This works reliably, has been stable since React 16.6, and I use it on every project. The AdminPanel bundle isn't loaded until the user navigates to /admin. While it loads, the PageLoader component is shown.

Where it works well: Next.js App Router

In Next.js with the App Router, Suspense is deeply integrated with Server Components and streaming. A page can start sending HTML to the client while server-side data is still loading:

// app/dashboard/page.tsx
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<MetricsSkeleton />}>
        <Metrics />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  )
}

async function Metrics() {
  const data = await fetchMetrics() // slow query
  return <MetricsGrid data={data} />
}

async function RecentActivity() {
  const data = await fetchActivity() // another slow query
  return <ActivityFeed data={data} />
}

The page shell renders immediately. Each Suspense boundary resolves independently as its data arrives. If metrics load in 200ms and activity loads in 800ms, the user sees the metrics 600ms before the activity feed. No waterfall.

This is the best developer experience I've had for data fetching in React. You write async components, wrap them in Suspense, and the framework handles streaming, loading states, and error recovery.

Where it doesn't work well: client-side data fetching without a framework

Using Suspense for client-side data fetching without a framework is possible but awkward. You need a library that integrates with the Suspense protocol, which means it throws promises when data isn't cached.

React Query supports Suspense:

const { data } = useSuspenseQuery({
  queryKey: ['users'],
  queryFn: fetchUsers,
})

This works and eliminates the isLoading / data / error conditional rendering pattern. But it comes with caveats:

  1. Every Suspense-enabled query needs a Suspense boundary above it. If you forget the boundary, the thrown promise propagates up and can suspend a larger section of the UI than intended.

  2. Error handling requires an Error Boundary. Suspense queries that fail throw errors instead of returning them. You need an Error Boundary next to your Suspense boundary.

  3. Waterfall risk. If two Suspense-enabled queries are in the same component, they suspend sequentially. The second query doesn't start until the first resolves. To run them in parallel, they need to be in separate components within separate Suspense boundaries, or you need to prefetch.

The pattern I use in production

On Next.js App Router projects:

// Server Components with Suspense — the ideal path
<Suspense fallback={<Skeleton />}>
  <AsyncServerComponent />
</Suspense>

On client-side React projects:

// React Query without Suspense — explicit loading/error handling
function UserList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  })

  if (isLoading) return <Skeleton />
  if (error) return <ErrorMessage error={error} />
  return <List data={data} />
}

I use the Suspense mode of React Query selectively, for cases where the component tree benefits from the boundary-based loading model (dashboards with multiple independent data sources). For simpler pages with a single data dependency, the explicit loading/error pattern is clearer.

Where Suspense is heading

React 19 adds use(), a hook that can unwrap promises and contexts. Combined with server actions and server components, this moves React closer to a world where data fetching is declarative and loading states are structural rather than imperative.

The trajectory is clear: Suspense will become the standard way to handle async operations in React. For now, it's production-ready in framework contexts (Next.js, Remix) and usable but less polished for client-side-only applications.

RESPONSES
Kenji NakamuraJul 10, 2024

The distinction between SSR and RSC in the Server Components post was good, but this one clarifies the Suspense side of it. The waterfall risk with multiple useSuspenseQuery calls in the same component is something I hit last week and didn't understand until reading this.

Leave a response