skip to content

Suspense for Data

Under the hood a component suspends by throwing a promise, and the use() hook makes reading a promise during render a first-class API. Interviewers ask this to see whether you understand why a raw fetch in render causes an infinite refetch loop.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

4

A React 19 Client Component contains `const user = use(fetch('/api/user').then(r => r.json()))` directly in its body. The UI never leaves the loading fallback and the network panel fills with repeated requests to /api/user. What is happening, and what fixes it?

level: middleimportance: must knowfreq 52%

answer

  1. new promise identity every render
  2. retry re-runs the body
  3. fetch called again on each attempt
  4. promise must be keyed and stable
  5. read a resource, don't create one

basics

~20 s

Each render calls fetch again, so use() receives a brand-new pending promise every time. React retries the render when a promise settles, the retry starts another request, and it suspends again — an endless fetch-suspend-retry loop.

solid answer

~50 s

The promise is created *during* render, so it has a new identity on every render attempt. React suspends on it, waits for it to settle, then retries by re-running the component body — which calls `fetch` again and hands `use` a different, still-pending promise. React has no way to know it is the same request, so it suspends again, and the cycle never terminates. The fix is to make the resource stable across attempts: the promise must be created outside the render and looked up by key. That means passing a promise down as a prop from a Server Component, reading it from a module-level cache keyed by the request (URL plus params), or letting a data library own that cache. React 19 even warns in development that a Client Component suspended on an uncached promise. Wrapping the fetch in `useMemo` is not a fix — memo results from a render that never committed are not guaranteed to survive.

code

javascript · 14 lines
javascript
const cache = new Map();

export function fetchJSON(url) {
  if (!cache.has(url)) {
    cache.set(
      url,
      fetch(url).then((r) => {
        if (!r.ok) throw new Error(`Request failed: ${r.status}`);
        return r.json();
      })
    );
  }
  return cache.get(url);
}

go deeper

for a junior

Recognise the symptom and the rule behind it: never call fetch straight inside a component body when you are reading it with use(). Data comes in as a prop or from a cache created outside the component.

for a middle

Explain the loop step by step — new promise each attempt, suspend, retry, new promise — and show a keyed module cache that returns the identical promise so the retry reads a settled value.

for a senior

Diagnose it from evidence: a stuck fallback plus unbounded requests at endpoint latency. Distinguish it from a promise that never settles and from an effect with unstable dependencies, and say where the request-identity cache belongs in the architecture.

for a principal

Set the standard that components read resources and never create them, and decide who owns the cache — framework, query library, or a small in-house one — so the whole codebase cannot reintroduce this class of bug.

## The bug in one sentence Suspense is built on *retry*, and retry re-runs the component. A promise created inside the component body therefore cannot survive its own retry. ## Walking the loop ```jsx function Profile() { // new promise on every single render attempt const user = use(fetch('/api/user').then((r) => r.json())); return <h1>{user.name}</h1>; } ``` 1. React renders `Profile`. `fetch` fires request #1 and returns promise P1, still pending. 2. `use(P1)` suspends. React unwinds the subtree, shows the nearest fallback, and subscribes to P1. 3. P1 settles. React schedules a retry. 4. The retry calls the body again. `fetch` fires request #2 and returns a **different** promise, P2, pending. 5. `use(P2)` suspends. Go to 2. Nothing in that loop is broken code on React's side. React's contract is "if a render suspends on a pending promise, retry when it settles". Each retry honestly encounters a pending promise it has never seen. The identity of the resource is the thing the component failed to supply. A useful way to state the underlying rule: **the resource must be a function of the inputs, not of the render.** Two renders with the same props must produce the *same* promise object, not two equivalent ones. ## Why memoisation is not the fix The reflex answer is `useMemo(() => fetch(url).then(r => r.json()), [url])`. It is wrong for a specific reason: the render that computed the memo **never committed**, because it suspended. React is free to discard work from an abandoned render, and `useMemo` is documented as a performance hint rather than a semantic cache. Depending on it for correctness across a suspend is depending on unspecified behaviour. The same objection applies to `useRef` written during render, and `useState` initialisers are no better because the component instance's state is not established by a render that never mounted. ## What an actual fix looks like **Create the promise outside React.** A module-level cache keyed by request identity gives every render attempt the identical promise: ```js const cache = new Map(); export function fetchUser(id) { const key = `/api/user/${id}`; if (!cache.has(key)) { cache.set(key, fetch(key).then((r) => r.json())); } return cache.get(key); } ``` Now `use(fetchUser(id))` suspends on P1, retries, calls `fetchUser(id)` again, gets **P1 back already fulfilled**, reads the value synchronously, and the render completes. One request, one suspend, one retry. **Or hand the promise in from outside.** In a Server Components setup, the server starts the request and passes the promise down as a prop; the Client Component only reads it with `use`. The promise's identity then belongs to the parent render, not to the child's retry, so the loop cannot form. **Or use a data library.** Query libraries exist largely because that cache needs far more than a `Map`: keys, in-flight deduping, invalidation, garbage collection, and transfer of server-fetched data to the client. React deliberately does not ship one. ## Diagnosing it in the wild The signature is unmistakable once seen: a fallback that never clears, plus a network panel that grows without bound at roughly the latency of the endpoint. Related but distinct symptoms are worth separating — a fallback that never clears with *no* repeated requests usually means a promise that never settles; repeated requests *without* a stuck fallback usually means an effect with an unstable dependency, not Suspense at all. In development React 19 also warns explicitly that a component suspended on an uncached promise created during render, and points you at a framework or Suspense-compatible library. Treat that warning as the same bug caught early. ## The rule to carry away Inside a component you may **read** a resource, never **create** one. Creation happens above the component — in a parent, a module cache, or a library — where it can be keyed and reused. That single rule removes the entire class of Suspense refetch loops.

  • Why is wrapping the fetch in useMemo with the right dependencies not a reliable fix?
    Because the render that computed the memo suspended and never committed, and React may discard work from an abandoned render. useMemo is a performance hint with no persistence guarantee, so the retry can legitimately recompute it and produce a fresh promise. Stability has to come from outside React's render bookkeeping — a keyed cache or a promise passed in as a prop.
  • How does passing a promise from a Server Component into a Client Component avoid the same loop?
    The promise is created by the server render and arrives as a prop, so the client component's retries all read the same prop value. Retrying the child does not re-create the request, because the child never created it. The client component's only job is to read it with use() and render the result.
  • What would you check first if the fallback never clears but there are no repeated requests?
    That points away from the refetch loop and toward a promise that simply never settles — a request with no timeout, an aborted signal whose promise was swallowed, or a resource that was cached in a pending state and never resolved. Check whether the cached promise settles at all, and whether a rejection is being caught and dropped.

saying these in an interview costs you the question

  • Blames the Suspense boundary for remounting the component
  • Says useMemo makes the promise stable enough for Suspense
  • Thinks React automatically dedupes identical fetch calls
  • Claims the browser cache should have prevented the requests
  • Says adding another <Suspense> boundary fixes it

context

open as a page

In React 19, what actually happens inside React when a component suspends while rendering, and what does that imply about the work the component had already done in that render?

level: middleimportance: should knowfreq 44%

basics

~20 s

Suspending unwinds that component's render: React abandons the in-progress work, shows the nearest Suspense fallback, subscribes to the pending promise, and runs the component again from the top when it settles. Nothing it rendered is committed.

open as a page

Suspense data reading is usually described as requiring a cache keyed by request identity. Beyond memoizing a resolved value, what must such a cache guarantee for use() to behave correctly in React 19?

level: seniorimportance: should knowfreq 36%

basics

~20 s

It must return the identical promise for a given key on every render, dedupe requests already in flight, remember rejections as well as results, and be scoped per request on the server so data cannot leak between users.

open as a page

React 19 ships use() and <Suspense> but no client-side data cache of its own. Which responsibilities does React deliberately leave to a framework or data library, and how would you decide who owns them in an app you are leading?

level: principalimportance: should knowfreq 26%

basics

~20 s

React defines only the render-time protocol: suspend on a pending promise, retry when it settles, surface rejection to an error boundary. Caching, deduping, invalidation, revalidation, retries and server-to-client data transfer are policy, and a framework or library supplies them.

open as a page