Skip to content
React rc data 4 min read

Suspense for Data Fetching

Most data fetching code is dominated by the waiting — isLoading flags, ternaries that render spinners, and early returns scattered across every component that touches the network. Suspense flips that model around: instead of each component checking whether its data has arrived, a component simply suspends while it waits, and a single parent boundary decides what to show in the meantime. The result is loading UI that is declarative, composable, and far easier to coordinate across a tree.

What it means to suspend

A component “suspends” when, during render, it reads a resource that is not ready yet. React notices this, pauses that subtree, and walks up to find the nearest <Suspense> boundary, rendering that boundary’s fallback instead. When the data resolves, React retries the render and the real UI replaces the fallback — no useState, no useEffect, no manual loading branch.

The key idea is that the component code reads data as if it were already there:

import { Suspense } from "react";

function UserProfile({ userId }) {
  const user = fetchUser(userId); // reads a resource; may suspend
  return <h1>{user.name}</h1>;
}

function Page() {
  return (
    <Suspense fallback={<p>Loading profile…</p>}>
      <UserProfile userId="42" />
    </Suspense>
  );
}

There is no loading branch inside UserProfile. The component describes only the success state; the <Suspense> boundary owns the loading state for everything beneath it.

The use hook

In React 19, use is the standard, blessed way to make a component suspend on a Promise. You pass it a Promise and it either returns the resolved value or suspends until the Promise settles. Unlike other hooks, use may be called conditionally and inside loops.

import { use, Suspense } from "react";

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise); // suspends until resolved
  return (
    <ul>
      {comments.map((c) => (
        <li key={c.id}>{c.text}</li>
      ))}
    </ul>
  );
}

function Thread({ commentsPromise }) {
  return (
    <Suspense fallback={<p>Loading comments…</p>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

Create the Promise outside the component that calls use, or cache it — never create a fresh Promise inside a render that use consumes. A new Promise on every render means the value is never “the same,” so the component suspends forever. Frameworks and data libraries handle this caching for you; do not roll it by hand for real fetches.

Library integration

You rarely call use against a raw fetch Promise in production, because you still need caching, deduplication, and revalidation. Suspense is designed to be the rendering layer on top of a cache, and the major data libraries expose a Suspense mode that suspends instead of returning an isLoading flag.

import { useSuspenseQuery } from "@tanstack/react-query";
import { Suspense } from "react";

function Repos({ org }) {
  const { data } = useSuspenseQuery({
    queryKey: ["repos", org],
    queryFn: () =>
      fetch(`https://api.github.com/orgs/${org}/repos`).then((r) => r.json()),
  });
  return (
    <ul>
      {data.map((repo) => (
        <li key={repo.id}>{repo.full_name}</li>
      ))}
    </ul>
  );
}

function ReposPanel() {
  return (
    <Suspense fallback={<p>Loading repositories…</p>}>
      <Repos org="facebook" />
    </Suspense>
  );
}

With useSuspenseQuery, data is always defined inside Repos — the loading case can never reach the component body, so the type narrowing and the code both get simpler.

APIReturns while loadingLoading UI owned byUse when
useEffect + fetchnull / loading flagThe component itselfTrivial one-off requests
useQuery (TanStack)isPending: trueThe component (ternary)You want explicit branches
useSuspenseQuerySuspends<Suspense> boundaryDeclarative loading UI
use(promise)Suspends<Suspense> boundaryFramework-provided Promises

Coordinating fallbacks and errors

Because boundaries are just components, you place them wherever you want the loading granularity to live. One boundary high in the tree gives a single page-level spinner; several smaller boundaries let independent sections stream in as their data arrives.

function Dashboard() {
  return (
    <main>
      <Suspense fallback={<HeaderSkeleton />}>
        <ProfileHeader />
      </Suspense>
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </main>
  );
}

Suspense handles the pending state, but not failure. Pair every boundary with an error boundary so a rejected fetch renders a recoverable error UI instead of crashing the tree.

import { ErrorBoundary } from "react-error-boundary";
import { Suspense } from "react";

function Section() {
  return (
    <ErrorBoundary fallback={<p>Could not load this section.</p>}>
      <Suspense fallback={<p>Loading…</p>}>
        <ActivityFeed />
      </Suspense>
    </ErrorBoundary>
  );
}

On the server, the same boundaries enable streaming SSR: React sends the shell with fallbacks immediately, then streams each section’s HTML as its data resolves, so users see content progressively rather than waiting for the slowest query.

Best Practices

  • Let components read data as if it is already present, and put loading UI in <Suspense fallback> rather than in component branches.
  • Never create the Promise that use consumes inside render — cache it or let a library/framework own it.
  • Prefer a library’s Suspense mode (useSuspenseQuery, SWR suspense: true) over hand-fed Promises so you keep caching and dedup.
  • Wrap every Suspense boundary in an error boundary; Suspense covers pending, not failed.
  • Choose boundary granularity deliberately — one boundary for a page-wide spinner, several for independent sections that stream in.
  • Use skeletons that match the final layout to avoid jarring shifts when the fallback is replaced.
Last updated June 14, 2026
Was this helpful?