use() + fetch, measured β the silent infinite loop, the useMemo that won't save you, and the cache that willReact 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.
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.
use() needs React 19+ (npm i react@19 react-dom@19); we measured on 19.3.0.use(fetch(...)) fired 110 requests in 5 seconds, silentlyThis 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:
console.error and console.warn and captured the entire session: zero messages on React 19.3.0. No "uncached promise" warning, no loop warning. Your only symptoms are a spinner that never resolves and a network tab (or an API bill) that's on fire.<StrictMode>: 114 fetches in 5 seconds. Removing StrictMode β the classic cargo-cult fix for double-fetching β changes nothing here.useMemo won't save you β 128 fetches, measuredThe 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.
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.
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.
mock_delayReal 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.
error returnuse() 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.
display:noneChange 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-shows | yes | no |
loaded page-1 <ul> during fetch | still in the DOM, but display:none | display:block β stays on screen |
| pending signal | none | isPending === 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.
?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.
&_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.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.
POST /auth/login returns real signed JWTs; test login flows and 401 handling without a backend. Guide.?mock_chaos=0.3 fails a random 30% of requests; ?mock_jitter=800 randomizes latency. Great for soak-testing Β§4's cache + Β§9's retry together.?mock_validate=1 type-checks your POST bodies and returns 422s with field errors./graphql, plus generated openapi.json and types.ts?format=zod for typed clients.| Tool | What it's great at | Trade-off vs this setup |
|---|---|---|
| TanStack Query / SWR | Production-grade client caches: dedup, revalidation, retries, useSuspenseQuery works with use()-style Suspense out of the box | The 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 loaders | Framework-managed data fetching; RSC makes async components legal on the server | Solves 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. |
| MSW | In-browser request interception; tests run offline | Mocks 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-server | Local fake REST, huge ecosystem | Needs 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.