skip to content

A React 19 client component calls `const user = use(fetch('/api/user/' + id).then(r => r.json()))` directly in its render body. It fetches endlessly and React logs a warning about an uncached promise. Explain the mechanism and how you would supply the promise instead.

level: seniorimportance: should knowfreq 40%

answer

  1. a new object every render
  2. object identity is the cache key
  3. use() reads, it does not own
  4. suspend, retry, refetch, repeat
  5. the promise must outlive the render

basics

~20 s

Every render creates a brand-new pending promise, so the component suspends, resumes, renders again, and starts another request — an endless loop. React does not cache promises created during a client render; the promise must be created outside render and passed in.

solid answer

~50 s

`use` reads a promise; it does not own or cache one. Creating the promise inside the render body means each render produces a *different* promise object that is pending, so the component suspends again, fires another request, and never converges — React warns that it was suspended by an uncached promise. The fix is to give the component a stable promise from outside its own render: start the request in a Server Component and pass the un-awaited promise down as a prop, or hold it in a module-level or request-scoped cache keyed by the id so the same key always yields the same promise object, or let a data library that caches per key hand you one. Wrapping the `fetch` in `useMemo` is not a fix — React may discard a memo cache, and the promise would be recreated.

code

javascript · 12 lines
javascript
// Stable promise per id, created outside any render.
const cache = new Map();

export function getUser(id) {
  if (!cache.has(id)) {
    cache.set(
      id,
      fetch('/api/user/' + id).then((r) => r.json())
    );
  }
  return cache.get(id);
}

go deeper

for a junior

Remember the rule of thumb: never create the promise inside the component that calls use(). Take it as a prop or from a helper defined outside the component.

for a middle

Trace the loop out loud — new promise per render, pending, suspend, retry, new promise — and say that React matches a resource by object identity, so the same object must come back on the retry.

for a senior

Show where the promise should live: started on the server and passed down, or returned by a keyed cache or data layer that also owns dedup, cancellation and invalidation. Name the tradeoff of a hand-rolled module Map, including cross-request sharing on the server.

for a principal

Own the boundary decision: which layer holds request identity and lifetime for the whole app. Argue why component-local caches do not scale and what you standardise on so this bug class cannot reappear in review.

## The loop, step by step ```jsx function User({ id }) { const user = use(fetch('/api/user/' + id).then((r) => r.json())); // broken return <p>{user.name}</p>; } ``` 1. React renders `User`. The render body calls `fetch`, producing promise **A** (pending) and firing request #1. 2. `use(A)` sees pending, so React suspends the component and shows the nearest Suspense fallback. 3. Promise **A** fulfills. React retries the render. 4. The render body runs from the top again — and `fetch` is called again, producing promise **B**, a different object, also pending, firing request #2. 5. `use(B)` sees pending. Suspend again. Go to step 3. Nothing ever completes, and the network tab fills with identical requests. React detects the shape of this problem and warns that a component suspended on a promise it has no way to cache. ## Why React cannot rescue you here For `use(promise)` to converge, the *same promise object* must be handed back on the retry render, so that by then it is already fulfilled and `use` can return its value. React identifies a resource by object identity, not by the URL or the arguments that produced it — it has no idea that promise A and promise B represent the same request. And it deliberately does not cache promises created during a client render: doing so would mean inventing a cache key out of the call site and the surrounding data, which is precisely the job of a data layer. So the invariant to internalise is: **`use` is a reader, and the caller owns the cache.** ## The supported ways to supply the promise **Pass it down from the server.** Start the request in a Server Component and hand the un-awaited promise to a client component as a prop. The server component runs once, so the promise is created once; React streams the eventual value to the client, where `use` unwraps it. ```jsx // server component function Page({ id }) { const userPromise = loadUser(id); // not awaited return ( <Suspense fallback={<p>Loading…</p>}> <User userPromise={userPromise} /> </Suspense> ); } ``` **Cache it by key on the client.** Keep a `Map` from key to promise outside the component, so the same `id` always yields the same object across renders: ```js const cache = new Map(); function getUser(id) { if (!cache.has(id)) cache.set(id, fetch('/api/user/' + id).then((r) => r.json())); return cache.get(id); } ``` This converges, but a hand-rolled `Map` never invalidates and is shared across users on the server, so it is a demo-grade solution rather than a production one. **Let a caching data layer own it.** A library or framework that already deduplicates and caches requests per key can return a stable promise for a given key; that is the boundary where retries, invalidation and staleness belong. On the server specifically, React's `cache()` memoizes a function's result for the duration of a request pass — useful for deduplicating the same lookup across several Server Components — but it is a Server-Component-only API and does not solve the client-render case. ## Why useMemo is not the fix Candidates reach for `useMemo(() => fetch(url), [url])`. It usually appears to work and is still wrong in principle: React treats a memo cache as a performance hint it is allowed to discard, so nothing guarantees the same promise across renders. It also does not survive remounts, and it hides the ownership question — the component still pretends to own a request it has no way to cancel, dedupe, or invalidate. ## The review heuristic When you see `use(` in a diff, look at the argument. If the expression that produces the promise is written inline in the render body, that is the bug, whatever the surrounding code looks like. A correct call site reads a promise that was already handed to it — a prop, a cached lookup, or something a data layer returned.

  • Would useMemo around the fetch make this correct?
    No. React documents the useMemo cache as a hint it may discard, so a new promise can appear on a later render and restart the loop, and it does not survive a remount. It also leaves request ownership — dedup, cancellation, invalidation — inside a component that cannot do any of it.
  • How does the request get cancelled if the user navigates away mid-flight?
    Not by use(), which only reads the promise. Cancellation belongs to whoever created it: the data layer that owns the request, or code that holds the AbortController alongside the cached promise. That separation is another reason the promise should not be born inside render.
  • What changes if the id prop changes while a promise for the old id is in flight?
    With a keyed cache, the render for the new id looks up a different promise, so the component suspends on the new request and the stale one is simply no longer read. With an inline fetch there is no key at all, which is part of why the pattern cannot behave correctly.

saying these in an interview costs you the question

  • Assumes React caches promises created during render
  • Says useMemo around the fetch makes it correct
  • Blames StrictMode double rendering for the repeated requests
  • Thinks the fix is calling use() inside useEffect
  • Believes React can dedupe by URL rather than object identity

context