<Suspense>

Shows a fallback UI until the children inside the boundary have finished loading.

Signature
<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>
  • Added in React 16.6.0

Parameters

ParameterTypeDescription
$childrenReactNodeThe actual UI you intend to render. If children suspends while rendering, the boundary switches to rendering fallback.
$fallbackReactNodeAn alternate UI rendered in place of the real UI until it has finished loading. Any valid React node is accepted, though in practice a lightweight placeholder such as a spinner or skeleton. If the fallback itself suspends, the closest parent boundary activates.
$deferoptionalboolean= falseExperimental. When true, React may show the fallback first and render or stream children later, even when nothing inside them suspends. Intended for content that is expensive to render.

Return value

ReactNodeEither `children`, or `fallback` while anything inside `children` is suspended.

Errors

React throws "A component suspended while rendering, but no fallback UI was specified" when something suspends with no boundary anywhere above it.

Changelog

  • PHP 16.6.0Introduced for code splitting alongside lazy().
  • PHP 18.0.0Suspending inside startTransition or useDeferredValue keeps the already-visible content on screen instead of falling back to the placeholder.
  • PHP 19.2.0Suspended content is revealed at most once every 300ms, measured from the last reveal, so boundaries that become ready within that window are revealed together.

A boundary marks a region of the tree that is allowed to be incomplete. When any component inside it suspends, React discards the partial render, shows the fallback, and retries once the pending work resolves — no isLoading flag, no null-checking every field.

What actually activates it

  • use(promise) on a promise from a cache or a Server Component.
  • Components loaded with lazy().
  • Streaming from a Suspense-enabled framework or server renderer — including a boundary large enough that its HTML has not finished arriving, even when nothing inside it suspends.
  • A stylesheet rendered with <link rel="stylesheet"> and a precedence prop: React blocks the boundary until the stylesheet loads, up to a timeout.

Data fetched in an Effect or an event handler is invisible to it, which is why wrapping a useEffect-based component in a boundary appears to do nothing.

What trips people up

The promise must be created outside render, or cached. A promise built during render is a new promise on every attempt, so the boundary suspends forever and the fallback never clears.

Placement decides granularity: one boundary at the top of a page means the whole page blanks, while a boundary per section reveals them independently. Once content has been shown, an update that suspends again will swap it back for the fallback — unless the update is wrapped in startTransition or driven by useDeferredValue, which keeps the stale content visible instead.

Hiding visible content also cleans up its layout Effects and re-fires them on reveal.

Example

Try it yourself
Loading editor…
Loading React…

When to use it

  • Render a placeholder while a route-level component loaded with lazy() arrives.
  • Read a cached data promise with use() and show a skeleton until it resolves.
  • Give each panel of a dashboard its own boundary so slow sections do not block fast ones.
  • Keep the previous search results on screen during a transition instead of flashing a spinner.

Frequently asked questions

Why does my fallback never disappear?

Almost always because the promise is created inside the component body, so each retry sees a brand-new pending promise. Move the promise to module scope, a cache keyed by its inputs, or a framework data loader.

Does it catch data fetched in useEffect?

No. It only activates for use() on a promise, components from lazy(), and streaming server rendering. A component that fetches in an Effect renders normally with empty state, so the boundary never sees anything to wait for.

How do I stop the spinner flashing on every update?

Wrap the state update in startTransition, or feed the changing value through useDeferredValue. React then keeps the previously rendered content visible instead of replacing it with the fallback while the new content loads.

More examples

Independent boundaries per section

Both placeholders appear at once, then the stats line replaces its placeholder while the feed is still loading. A single boundary wrapped around both would hold the faster section back until the slower one resolved.

Example · jsx
import { Suspense, use } from "react";

function delay(value, ms) {
  return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}

const statsPromise = delay({ users: 1240, sessions: 8130 }, 400);
const feedPromise = delay(["deploy finished", "index rebuilt", "cache warmed"], 1200);

function Stats() {
  const stats = use(statsPromise);
  return (
    <p>
      {stats.users} users / {stats.sessions} sessions
    </p>
  );
}

function Feed() {
  const events = use(feedPromise);
  return (
    <ul>
      {events.map((event) => (
        <li key={event}>{event}</li>
      ))}
    </ul>
  );
}

export default function Dashboard() {
  return (
    <div>
      <h2>Dashboard</h2>
      <Suspense fallback={<p>Loading stats…</p>}>
        <Stats />
      </Suspense>
      <Suspense fallback={<p>Loading feed…</p>}>
        <Feed />
      </Suspense>
    </div>
  );
}

Code splitting with lazy()

Nothing is downloaded until the button is pressed. On the first click the fallback paragraph shows while the chunk is fetched, then the panel replaces it. Subsequent opens are instant because the module is already resolved.

Example · jsx
import { Suspense, lazy, useState } from "react";

// In a real app this is a separate chunk fetched on first render.
const SettingsPanel = lazy(() => import("./SettingsPanel.js"));

export default function Settings() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(true)}>Open settings</button>
      {open ? (
        <Suspense fallback={<p>Loading settings…</p>}>
          <SettingsPanel />
        </Suspense>
      ) : (
        <p>Panel not loaded yet.</p>
      )}
    </div>
  );
}

Avoiding a fallback flash with a transition

The fallback is shown once, on first mount. Clicking the button suspends again, but because the state update runs inside startTransition the existing list stays on screen and only the inline "updating…" indicator appears until the new results are ready.

Example · jsx
import { Suspense, use, useState, useTransition } from "react";

const cache = new Map();

function results(query) {
  if (!cache.has(query)) {
    cache.set(
      query,
      new Promise((resolve) =>
        setTimeout(() => resolve([`${query} — first`, `${query} — second`]), 600)
      )
    );
  }
  return cache.get(query);
}

function Results({ query }) {
  const rows = use(results(query));
  return (
    <ul>
      {rows.map((row) => (
        <li key={row}>{row}</li>
      ))}
    </ul>
  );
}

export default function Search() {
  const [query, setQuery] = useState("alpha");
  const [isPending, startTransition] = useTransition();

  return (
    <div>
      <button onClick={() => startTransition(() => setQuery("beta"))}>
        Search "beta"
      </button>
      {isPending ? <span> updating…</span> : null}
      <Suspense fallback={<p>Loading results…</p>}>
        <Results query={query} />
      </Suspense>
    </div>
  );
}

Discussion

  • Be the first to comment on this lesson.