<Suspense fallback={<Spinner />}> looks like a loading-state wrapper, and that reading isn't wrong, but it undersells what's actually happening. Suspense is a mechanism for a component to tell React "I'm not ready yet, try again once this promise resolves" — and React handles the coordination of showing a fallback, retrying, and committing the result, across as many nested async components as needed, without any of them managing their own isLoading flag.

What "Suspending" Actually Means

A component suspends by throwing a promise during render — not an error, a promise. React catches it, walks up to the nearest <Suspense> boundary, shows that boundary's fallback, and waits. When the promise resolves, React re-renders the component, and if it doesn't throw again, the real content commits.

You don't write the throw yourself in idiomatic usage — a data-fetching library or framework does it for you. This is the resource-based read pattern:

jsx
function ProductDetails({ resource }) {
  const product = resource.product.read(); // throws while pending, returns data once resolved
  return <h1>{product.name}</h1>;
}

function ProductPage({ id }) {
  const resource = useMemo(() => fetchProduct(id), [id]);
  return (
    <Suspense fallback={<ProductSkeleton />}>
      <ProductDetails resource={resource} />
    </Suspense>
  );
}

Compare this to the manual alternative every React developer has written dozens of times — a useState for data, a useState for loading, a useState for error, a useEffect to kick off the fetch, and conditional rendering for all three states, repeated in every component that fetches something. Suspense moves the loading-state branching out of the component entirely and into the boundary.

Nested Boundaries Give You Fine-Grained Loading States for Free

Multiple <Suspense> boundaries at different levels let independent parts of a page resolve on their own schedule instead of blocking on the slowest one:

jsx
function Dashboard() {
  return (
    <>
      <Suspense fallback={<HeaderSkeleton />}>
        <UserHeader />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <RecentOrders />
      </Suspense>
    </>
  );
}

If RevenueChart is slow and the other two are fast, the header and orders table appear immediately while only the chart's boundary shows its skeleton — no manual coordination of three separate loading flags required to get that behavior.

Waterfalls Are Still Your Responsibility

Suspense coordinates display, not fetch timing. If ProductDetails fetches inside its own render and a child of it fetches something that depends on the parent's result, you still get a sequential waterfall — Suspense just hides the ugliest symptom (layout shift, flickering spinners) without fixing the underlying latency. Kick off independent fetches in parallel before rendering, not inside nested suspending components:

jsx
function ProductPage({ id }) {
  // both start immediately, not one-after-the-other
  const productResource = useMemo(() => fetchProduct(id), [id]);
  const reviewsResource = useMemo(() => fetchReviews(id), [id]);

  return (
    <Suspense fallback={<PageSkeleton />}>
      <ProductDetails resource={productResource} />
      <Reviews resource={reviewsResource} />
    </Suspense>
  );
}

Error Boundaries Are the Other Half

Suspense handles the "not ready yet" case; it says nothing about the "failed" case. A rejected promise still needs an ErrorBoundary wrapping the Suspense boundary, because a thrown rejection propagates like any other render error:

jsx
<ErrorBoundary fallback={<ProductError />}>
  <Suspense fallback={<ProductSkeleton />}>
    <ProductDetails resource={resource} />
  </Suspense>
</ErrorBoundary>

Don't Roll Your Own Resource Cache

The resource.read() pattern above is illustrative, but hand-rolling a correct suspending cache — one that dedupes concurrent reads, handles cache invalidation, and doesn't leak — is genuinely hard to get right. In practice, reach for a library built for this (React Query's Suspense mode, SWR's suspense: true, or a framework's built-in data layer like Next.js's fetch integration) rather than writing the throw-a-promise plumbing by hand in application code.