Suspense, lazy and use()

React · Advanced · 16 min

Suspense replaces scattered isLoading flags with declarative loading UI and is the foundation of streaming and data loading in React 19.

Suspense lets a component "wait" for something before rendering, while you declare the loading UI once, higher up:

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>

If anything inside suspends, React shows the nearest boundary's fallback until it's ready, then reveals the content. Things that suspend:

  • `lazy` components: const Editor = lazy(() => import("./Editor.jsx")) loads code on first render, which is how you split big bundles by route or feature.
  • `use(promise)` in React 19: reads the value of a promise during render. If it's pending, the component suspends; if it rejects, the nearest error boundary catches it.
  • Suspense-enabled data sources: framework data loading, Server Components, TanStack Query's useSuspenseQuery.

use is special among Hooks: it can be called conditionally and in loops. The promise must be stable: created outside rendering (by a cache, a router loader or a parent) or passed from a Server Component. A promise created in the same component's render is new every time and would suspend forever. Suspense does not detect data fetched in an effect.

Design notes:

  • Place boundaries where a loading state makes sense to the user. Nested boundaries reveal content progressively, outer first.
  • Siblings inside one boundary are revealed together, avoiding a pop-in cascade.
  • When an update would hide already-visible content behind a fallback, wrap it in a transition (next lesson) and React keeps showing the old UI instead.
  • Since React 19, when a component suspends, React shows the fallback immediately and then "pre-warms" siblings, so commits aren't delayed by sibling rendering.

Example

import { Suspense, lazy, use, useState } from "react";

const Chart = lazy(() => import("./Chart.jsx"));   // separate bundle chunk

// A simple cache so the promise is stable across renders
const cache = new Map();
function fetchUser(id) {
  if (!cache.has(id)) {
    cache.set(id, fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then(r => r.json()));
  }
  return cache.get(id);
}

function UserName({ id }) {
  const user = use(fetchUser(id));   // suspends until resolved
  return <h2>{user.name}</h2>;
}

export default function Dashboard() {
  const [showChart, setShowChart] = useState(false);
  return (
    <>
      <Suspense fallback={<p>Loading user...</p>}>
        <UserName id={1} />
      </Suspense>
      <button onClick={() => setShowChart(true)}>Show chart</button>
      {showChart && (
        <Suspense fallback={<p>Loading chart...</p>}>
          <Chart />
        </Suspense>
      )}
    </>
  );
}

Remember

  • Suspense shows the nearest boundary's fallback while children are not ready
  • lazy(() => import(...)) code-splits components; use(promise) reads promises during render
  • Promises passed to use must be stable (cached or created outside render)

Watch out for

  • Calling use(fetch(url)) directly in render, creating a new promise every render.
  • Expecting Suspense to show a fallback for data fetched in a useEffect.

Check yourself

  1. Which of these will make a Suspense boundary show its fallback?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: react.dev/reference/react/Suspense