Skip to content
React rc advanced 5 min read

Suspense

Suspense is React’s declarative mechanism for handling the waiting state of a UI. Instead of scattering isLoading flags and conditional spinners throughout your components, you wrap a part of the tree in a <Suspense> boundary and give it a fallback to show while anything inside is still loading. When a component inside the boundary “suspends” — because its code or its data is not ready yet — React pauses rendering that subtree, shows the fallback, and seamlessly swaps in the real content once everything resolves.

How suspending works

A component suspends by throwing a promise during render. You almost never do this by hand; instead you use APIs that integrate with Suspense — React.lazy for code, and Suspense-enabled data sources such as frameworks (Next.js, React Router) or libraries (React Query, Relay) for data. When React encounters a thrown promise, it walks up the tree to the nearest <Suspense> boundary, renders that boundary’s fallback, and subscribes to the promise. When the promise settles, React retries the render.

The mental model: a <Suspense> boundary represents “a region of the UI that can be in a loading state as a single unit.” Everything inside resolves together before the fallback is replaced.

Lazy-loaded components

The most common entry point is code-splitting with React.lazy. It returns a component that loads its module on first render, suspending until the chunk arrives.

import { Suspense, lazy } from "react";

const Dashboard = lazy(() => import("./Dashboard.jsx"));

export default function App() {
  return (
    <Suspense fallback={<p>Loading dashboard…</p>}>
      <Dashboard />
    </Suspense>
  );
}

lazy accepts a function that returns a promise resolving to a module with a default export. Until that import resolves, Dashboard suspends and the fallback renders in its place.

Fallbacks and boundary placement

The fallback prop accepts any React node — text, a spinner, or a full skeleton component. Where you place the boundary controls the granularity of loading: one boundary high in the tree shows a single fallback for a large area, while several smaller boundaries let independent sections load on their own schedule.

import { Suspense } from "react";
import Sidebar from "./Sidebar.jsx";
import Feed from "./Feed.jsx";
import Recommendations from "./Recommendations.jsx";

function Skeleton({ label }) {
  return <div className="skeleton" aria-busy="true">Loading {label}…</div>;
}

export default function Home() {
  return (
    <main>
      <Suspense fallback={<Skeleton label="sidebar" />}>
        <Sidebar />
      </Suspense>

      <Suspense fallback={<Skeleton label="feed" />}>
        <Feed />
        <Recommendations />
      </Suspense>
    </main>
  );
}

Here the sidebar and the feed area load independently. Feed and Recommendations share a boundary, so they appear together once both are ready.

Place boundaries at meaningful UI seams — a panel, a route, a card list — not around every component. Too many boundaries produce a stuttering cascade of spinners; too few make the whole screen wait on its slowest part.

Suspense for data

Data fetching becomes declarative when paired with a Suspense-aware source. The component reads data synchronously, and the framework handles suspending until it resolves. With React Query, for example:

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

function UserProfile({ userId }) {
  const { data } = useSuspenseQuery({
    queryKey: ["user", userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });

  return <h1>{data.name}</h1>;
}

export default function ProfilePage({ userId }) {
  return (
    <Suspense fallback={<p>Loading profile…</p>}>
      <UserProfile userId={userId} />
    </Suspense>
  );
}

useSuspenseQuery never returns an undefined data — the component only renders once the query has resolved, because it suspends until then. This eliminates loading-state branching inside the component.

Combining Suspense with transitions

When you trigger a state change that causes a boundary to re-suspend (such as navigating to new data), you usually do not want the existing content to flash back to a fallback. Wrapping the update in a transition keeps the current UI visible while the new content loads in the background.

import { Suspense, useState, useTransition } from "react";

export default function Tabs({ panels }) {
  const [active, setActive] = useState(0);
  const [isPending, startTransition] = useTransition();

  return (
    <>
      {panels.map((p, i) => (
        <button key={p.id} onClick={() => startTransition(() => setActive(i))}>
          {p.label}
        </button>
      ))}

      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <Suspense fallback={<p>Loading…</p>}>
          {panels[active].content}
        </Suspense>
      </div>
    </>
  );
}

On the first render of a boundary, the fallback shows. On subsequent updates wrapped in startTransition, React keeps the old panel mounted and dims it via isPending until the new one is ready — no jarring spinner flash.

SSR streaming

On the server, Suspense unlocks streaming HTML. With renderToPipeableStream (Node) or renderToReadableStream (Web/edge), React sends the shell immediately and streams each boundary’s content as its data resolves, injecting it into the page in place of the fallback.

import { renderToPipeableStream } from "react-dom/server";
import App from "./App.jsx";

function handler(req, res) {
  const { pipe } = renderToPipeableStream(<App />, {
    bootstrapScripts: ["/client.js"],
    onShellReady() {
      res.setHeader("content-type", "text/html");
      pipe(res);
    },
  });
}

The client then performs selective hydration: it hydrates each streamed boundary as it arrives and prioritizes whichever region the user interacts with first. Fast parts of the page become interactive without waiting for slow ones.

AspectWithout SuspenseWith Suspense
Loading stateManual isLoading flagsDeclarative fallback
SSR deliveryFull page after all dataStreamed shell + progressive chunks
HydrationAll-at-once, blockingSelective, prioritized by interaction
Re-fetch UXSpinner flashSmooth via transitions

Suspense catches pending states, not errors. A rejected data promise will not be shown by the fallback — pair every boundary with an Error Boundary to handle failures gracefully.

Best practices

  • Place boundaries at logical UI regions so each area’s loading state is independent and meaningful.
  • Pair every <Suspense> with an Error Boundary, since Suspense handles only the pending case, not rejection.
  • Use useTransition (or useDeferredValue) when updating data inside an already-mounted boundary to avoid fallback flashes.
  • Prefer Suspense-aware data libraries over hand-rolling promise throwing — manual implementations are easy to get wrong.
  • Design skeleton fallbacks that match the final layout to minimize layout shift when content swaps in.
  • On the server, stream with renderToPipeableStream/renderToReadableStream so slow boundaries never block the rest of the page.
Last updated June 14, 2026
Was this helpful?