open atlas
↑ Back to track
React, zero to senior RCT · 03 · 02

Suspense for data: the throw-a-promise contract, use(), and boundaries as UX design

Suspense inverts data loading: a component reads a promise with use(), React suspends to the nearest boundary, and resolve retries the render. Boundary placement is UX design, rejected promises route to error boundaries — and without a cache, raw fetch in render suspends forever.

RCT Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

The migration PR was titled “Suspense all the things” and it demoed beautifully: every isLoading ternary deleted, components that just read data. Two incidents followed within a week. First, staging started pinning a CPU core at 100% — a developer had written use(fetch('/api/user')) directly in a component body, and the profiler showed the same component rendering thousands of times per second: fetch a promise, suspend, retry, fetch a new promise, suspend again, forever. Nothing on screen but the fallback, network tab filling with hundreds of identical requests. Second, after that was patched with a cache, the support channel lit up: “the whole dashboard goes blank when I change the date filter.” The team had wrapped the entire page in a single <Suspense> at the root, so any widget refetching anywhere replaced all twelve widgets with one giant spinner. Same week, same feature, two opposite lessons: Suspense is a contract between a thrown promise and a boundary — and where you put the boundary is not plumbing, it is the loading UX itself, designed in JSX.

The contract: suspend by throwing, resume by retrying

Why does this matter to you as an engineer? Because understanding the throw-and-retry contract is the only way to diagnose the two failure modes from the Hook — infinite loops and page-wide blanks — and to place boundaries intentionally rather than by cargo-cult.

Mechanically, Suspense is an exception protocol. When a component cannot render because its data is not ready, it throws a thenable (promise-like object). React catches it while unwinding the render — remember, render is pure and discardable, so abandoning the attempt is free — walks up to the nearest <Suspense> boundary, and commits that boundary’s fallback instead of the subtree. React then subscribes to the thrown promise; when it resolves, React retries rendering the subtree from scratch. On the retry, the component runs again, the data source now has a value, the read returns synchronously, and the real UI commits. The component itself contains no loading branch — “not ready” is not a state it models, it is an exception it throws.

use() is the sanctioned read primitive: const user = use(userPromise) returns the resolved value, throws the promise to suspend if pending, and throws the rejection reason if rejected. Unlike hooks, use may be called inside conditionals and loops — it is a read, not a state registration. The promise itself should be created elsewhere (a parent, a router loader, a cache) and passed down or looked up; the component’s job is only to read.

const cache = new Map();
function fetchUser(id) {              // identity: same id → same promise
  if (!cache.has(id)) {
    cache.set(id, fetch(`/api/users/${id}`).then((r) => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    }));
  }
  return cache.get(id);
}

function Profile({ id }) {
  const user = use(fetchUser(id));    // suspends while pending
  return <h1>{user.name}</h1>;
}

// <ErrorBoundary fallback={<Oops />}>
//   <Suspense fallback={<Skeleton />}>
//     <Profile id={42} />
//   </Suspense>
// </ErrorBoundary>

Why raw fetch in render loops forever

Here is the trap from the Hook, mechanically. Render is pure and re-runnable: React may call your component many times, and each suspension discards the attempt entirely. If the component body itself creates the promise — use(fetch(url)) — then the retry after resolution runs the body again and creates a brand-new, pending promise. The one that resolved is gone with the discarded render; the new one suspends; React subscribes, it resolves, retry, new promise, suspend. The loop never converges, and every iteration fires a real network request. The fix is identity: some store outside render must return the same promise for the same logical request, so the retry’s use() call receives the promise that already resolved and reads through synchronously. That store is a cache — a Map keyed by request parameters in the toy version, a query library or framework loader in production. This is the structural reason React’s docs say Suspense-enabled data fetching is meant to be provided by frameworks and caching libraries rather than hand-rolled: the contract is easy, the cache (invalidation, eviction, deduplication) is the actual product.

Quiz

A component calls use(fetch('/api/user')) directly in its body. The fallback renders forever and the network tab fills with identical requests. What is the mechanism?

Boundaries are UX design, errors are the boundary’s twin

Where you place <Suspense> decides where spinners appear — that is a design decision expressed in component structure. One boundary at the page root means the entire page is all-or-nothing: simplest, but any slow widget blanks everything (the Hook’s second incident). A boundary per widget means each section reveals independently — fast widgets appear immediately, slow ones show local skeletons — at the cost of “popcorn UI”, content popping in random order and shifting layout. The senior pattern is deliberate grouping: wrap the parts users perceive as one unit (header + summary) in one boundary so they appear together, give long-tail sections (analytics, recommendations) their own. Nested boundaries compose: a suspension is caught by the nearest ancestor boundary, so inner boundaries scope inner loading states while the outer one covers initial mount. To control reveal ordering — top-to-bottom instead of whoever-resolves-first — React’s experimental SuspenseList coordinated multiple boundaries; until it stabilizes, the same effect comes from boundary structure or from awaiting combined promises higher up.

Rejection is the mirrored path: when the awaited promise rejects, the retry throws the rejection during render, and that exception travels to the nearest error boundary — not the Suspense boundary. A production-grade tree therefore pairs them: ErrorBoundary outside, Suspense inside, per region. Tradeoff to keep honest: fallbacks replace content. For updates after the initial load, replacing live data with a spinner is often worse than showing slightly stale data — which is why startTransition exists: updates marked as transitions do not trigger an already-revealed boundary’s fallback; React keeps the old UI while the new tree suspends in the background.

Quiz

A dashboard wraps twelve widgets in one root Suspense boundary. Changing a filter makes one widget refetch and the whole dashboard blanks to the spinner. What change fixes the UX without giving up Suspense?

Recall before you leave
  1. 01
    Walk through the full Suspense lifecycle for a component reading data with use(), covering the pending, resolved, and rejected paths — and explain precisely why the mechanism requires a cache.
  2. 02
    Your team asks where to put Suspense boundaries on a new dashboard and how updates should behave. What is the senior playbook, including the tradeoffs of each placement?
Recap

Suspense replaces modeled loading state with an exception protocol. A component reads its data with use(promise): a resolved promise returns its value synchronously, a pending one is thrown — React catches it while discarding the pure render attempt, commits the fallback of the nearest <Suspense> boundary, subscribes, and retries the subtree from scratch on resolution; a rejected one throws its reason to the nearest error boundary, which is why ErrorBoundary-outside-Suspense-inside is the standard pairing. The retry semantics dictate the cache requirement: because the retry re-runs the component body, a body that creates its own promise (use(fetch(url))) produces a fresh pending promise per attempt and suspends in an infinite loop of real network requests — convergence demands that a store outside render return the same promise for the same logical request, which is exactly what query libraries and framework loaders provide, and why hand-rolled Suspense fetching stops at toy caches. Boundary placement is the UX: suspensions bubble to the nearest boundary, so a single root boundary means all-or-nothing reveal while per-region boundaries reveal independently at the cost of popcorn ordering — group what users perceive as a unit, scope the rest, and use startTransition for updates so refetching replaces nothing that is already on screen. use may be called conditionally — it is a read, not a hook registration — and the promise should be created by a parent, loader, or cache, never by the reading component itself. Now when you see a boundary blanking too much of the page, or a component that suspends in an infinite loop, you have the exact lever: boundary placement is UX design, and promise identity is the cache’s job — reach for whichever one the symptom points at.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.