Skip to content
React rc data 4 min read

Suspense for Data Fetching

Most data fetching code is dominated by the waitingisLoading 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?