← All guides

React 19 use() + fetch, measured β€” the silent infinite loop, the useMemo that won't save you, and the cache that will

React 19's use() hook reads a promise and suspends until it resolves β€” pair it with <Suspense> and an error boundary and you get loading and error states with almost no code. The catch the docs bury: use() does not remember the promise for you. Hand it a promise you created during render and you get an infinite fetch loop β€” a completely silent one.

Everything below was reproduced and measured in a real Chrome on React 19.3.0 (Vite, client-side, StrictMode on unless noted) with an instrumented window.fetch before publishing. The numbers β€” 110 fetches in 5 seconds with zero warnings, 128 with the useMemo "fix", exactly 1 with the real fix, a loaded list literally set to display:none β€” are observed counts, not estimates.

1. Try it in 10 seconds (no signup)

A shared, self-resetting demo project is live right now:

curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"

That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, CodeSandbox, CI β€” anywhere. When you want your own schema it's one curl or one click β€” see Β§9.

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes. use() needs React 19+ (npm i react@19 react-dom@19); we measured on 19.3.0.

2. The loop: use(fetch(...)) fired 110 requests in 5 seconds, silently

This is the code everyone writes first, because it looks like it should work:

function Products() {
  // ❌ promise created during render
  const data = use(fetch("https://mockbird.mockbird.workers.dev/m/demo/products?limit=3")
    .then(r => r.json()));
  return <ul>{data.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
// rendered inside <Suspense fallback={<div>loading…</div>}>

What we measured: 40 fetches after 2 seconds, 110 after 5 β€” and the UI never left the fallback. The cycle: render creates a promise β†’ use() suspends β†’ the promise resolves β†’ React retries the render β†’ the retry creates a new promise β†’ suspend again, forever. Each loop is a real network request against your real API.

Two details that make this nastier than the usual footgun:

3. useMemo won't save you β€” 128 fetches, measured

The reflex fix β€” memoize the promise so re-renders reuse it:

function Products() {
  // ❌ still loops on initial mount
  const promise = useMemo(
    () => fetch(API + "/products?limit=3").then(r => r.json()), []);
  const data = use(promise);
  ...

Measured: 128 fetches in 5 seconds, still stuck on the fallback, still silent. The reason is subtle and worth internalizing: when the initial render suspends, it never commits β€” and React throws away the in-progress render's hook state, including your useMemo cell. The retry render runs the factory again and gets a fresh promise. useMemo only helps across re-renders of a component that has already mounted; a component that suspends on mount never gets there.

4. The fix: cache the promise outside the component β€” exactly 1 fetch

use() is designed to consume promises from something with a memory: a framework loader, a data library, or β€” for small apps and prototypes β€” a dozen lines of module-level cache:

const cache = new Map();
function getJson(url) {
  if (!cache.has(url)) cache.set(url, fetch(url).then(r => {
    if (!r.ok) throw new Error("HTTP " + r.status);
    return r.json();
  }));
  return cache.get(url);
}

function Products() {
  // βœ… same URL β†’ same promise, across suspends, retries, and StrictMode
  const data = use(getJson("https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"));
  return <ul>{data.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

Measured: exactly 1 fetch, list rendered, and the cache absorbs StrictMode's double-invoke for free. Invalidation is cache.delete(url) plus any state change to re-render (see the retry drill in Β§8). Note that React's built-in cache() API is for Server Components only β€” it won't do this job in the browser.

5. The async component that storms while it errors

The other tempting shape β€” just make the component async, like on the server:

// ❌ client component
async function Products() {
  const r = await fetch(API + "/products?limit=3");
  const data = await r.json();
  return <ul>…</ul>;
}

This one at least says something. Verbatim console error:

<Products> is an async Client Component. Only Server Components can be
async at the moment. This error is often caused by accidentally adding
`'use client'` to a module that was originally written for the server.

But it doesn't stop β€” while erroring, it also looped: 62 fetches in 2.5 seconds, rendering nothing. (An async function returns a promise; React treats the returned promise like Β§2's uncached one.) If you're in Next.js and this error surprises you, check for a stray 'use client'; if you're in a Vite SPA, there is no server β€” use Β§4's pattern.

6. Loading states you can actually see β€” mock_delay

Real APIs on localhost resolve in ~10 ms, so your Suspense fallback is an untested code path. Add ?mock_delay=2000 to any Mockbird URL and the response takes 2 s:

const data = use(getJson(API + "/products?limit=3&mock_delay=2000"));

Measured: fallback visible at 300 ms, data on screen after ~2 s, fallback gone. Now your skeleton screens, spinner alignment, and layout shift are testable by URL β€” no DevTools throttling, works in CI and Playwright too.

7. Rejected promises go to the error boundary β€” there is no error return

use() has no { data, error } shape. If the promise rejects, the component throws, and the nearest error boundary renders. Our fetcher in Β§4 deliberately throws on !r.ok β€” force the path with a simulated 500:

const data = use(getJson(API + "/products?mock_status=500"));

Measured: the boundary rendered HTTP 500, and React logged that it would "recreate this component tree from scratch using the error boundary you provided". Two things people miss: without a boundary the whole app unmounts on one failed fetch; and a plain fetch promise doesn't reject on HTTP errors at all β€” if your fetcher doesn't check r.ok, a 500 walks straight into your data.map() as an error object.

8. The page-change jank: your loaded list goes display:none

Change use()'s input (new page, new filter) with a plain setState and the Suspense boundary re-suspends the old content away. We measured exactly what happens to an already-rendered list during a 1.5 s page-2 fetch:

plain setPage(2)startTransition(() => setPage(2))
fallback re-showsyesno
loaded page-1 <ul> during fetchstill in the DOM, but display:nonedisplay:block β€” stays on screen
pending signalnoneisPending === true
const [isPending, startTransition] = useTransition();
const go = (p) => startTransition(() => setPage(p));
// old list stays visible, dim it with isPending while page p loads

Same components, same cache, one wrapper function β€” the difference between a flashing skeleton on every pagination click and a smooth transition. Test it honestly by making the fetch slow enough to see: &mock_delay=1500.

9. Deterministic retry tests β€” a scripted outage with ?mock_seq

"Retry" buttons in error boundaries are almost never tested against a recovery, because you can't ask production to fail twice and then succeed. Mockbird can:

const data = use(getJson(API + "/products?limit=2&mock_seq=503,503,200"));
// boundary retry handler:
onClick={() => { cache.clear(); setErr(null); }}   // new promise on next render

Measured: boundary shows HTTP 503 β†’ click retry β†’ HTTP 503 β†’ click retry β†’ the list renders. Exactly 3 fetches, in order β€” every response carries x-mockbird-seq: pos/len so you can assert the position. The sequence is per client IP and sticks on the last entry; isolate parallel test workers (or restart a drill) with &mock_seq_key=w1.

Gotcha we hit while building this guide: don't add a cache-buster like &_k=123 to get a fresh sequence β€” unknown query params are exact-match filters in Mockbird, so you'll get a perfectly healthy 200 [] and wonder where your products went. mock_seq_key is the reserved param for exactly that.

10. Get your own API (10 seconds, no signup)

curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
  -H "content-type: application/json" \
  -d '{"preset":"ecommerce"}'

Returns your project id, admin key, and a live base URL with seeded products, orders, customers, and reviews β€” full CRUD (writes persist), ?page/limit/sortBy/order/q, relations (_expand/_embed), X-Total-Count on every list. Or create it in one click and browse the data in the dashboard.

11. Beyond this guide

12. Honest comparison

ToolWhat it's great atTrade-off vs this setup
TanStack Query / SWRProduction-grade client caches: dedup, revalidation, retries, useSuspenseQuery works with use()-style Suspense out of the boxThe right answer for real apps β€” Β§4's Map is the concept they industrialize. They manage promises; they don't give you an API to call. Point them at Mockbird and you get both halves. (We measured SWR separately: guide.)
Next.js / React Router loadersFramework-managed data fetching; RSC makes async components legal on the serverSolves this class of bug by owning the promise lifecycle β€” if you're in a framework, prefer its loader. We measured TanStack Router’s loader traps separately: guide. The measurements here are for the client-side SPA case where you're on your own.
MSWIn-browser request interception; tests run offlineMocks live in your bundle and every teammate's setup. A hosted mock is one URL shared by the app, tests, CI, and a phone on your desk β€” and failure modes (mock_seq, mock_delay) are query params, not handler code.
json-serverLocal fake REST, huge ecosystemNeeds Node running on every machine. Mockbird speaks the same conventions (_page, _limit, db.json import/export) but is hosted, with auth simulation, failure injection, and snapshots on top.

Bias disclosure: this comparison is written by the Mockbird side. The measurements, though, are just measurements β€” rerun them in your own Chrome with the snippets as written. If you like use()-style Suspense but want a store, we measured Jotai async atoms too β€” its pager race is unloseable and the abort signal comes free.