SWR's pitch is "the fetch hook that fixes your fetch bugs" โ and it mostly delivers, but the failure modes it doesn't fix are exactly the ones people hit: error mysteriously undefined while the UI renders an error body as data, refreshInterval silently polling slower than configured, and optimistic updates that never roll back. This guide pairs SWR with a hosted mock API whose failures you can script โ and every number below was measured in a real Chrome on SWR 2.5.1 + React 18.3.1 (Vite) with an instrumented window.fetch before publishing. Nothing here is folklore.
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, or anywhere else โ no proxy config. When you want your own schema it's one curl or one click โ see ยง12.
The classic React fetch storm isn't fetch-in-render โ it's an object dependency in useEffect. The fetch sets state, the re-render creates a fresh { limit: 5 } that's never === the last one, the effect re-runs, forever:
// THE BUG: object dep โ new identity every render, effect re-runs every render.
function ProductList() {
const [items, setItems] = useState([]);
const opts = { limit: 5 }; // fresh object each render
useEffect(() => {
fetch(`${API}/products?limit=5`).then(r => r.json()).then(setItems);
}, [opts]); // โ never "equal"
return {items.map(p => - {p.name}
)}
;
}
Measured: 39 fetches after 2 seconds, 75 after 4, 148 renders โ it never stops, and the console shows zero warnings. React has no lint rule that catches a fresh object in a dependency array at runtime.
Now the same inline, non-memoized object โ as an SWR key:
function ProductList() {
const { data } = useSWR(
[`${API}/products?limit=5`, { limit: 5 }], // inline array + object key
([url]) => fetch(url).then(r => r.json()));
return {(data ?? []).map(p => - {p.name}
)}
;
}
Measured: exactly 1 fetch, 4 renders, list rendered. SWR 2 serializes keys stably (deep value equality, not identity), so the inline object that melts useEffect is simply a cache key. This is the single best reason to stop hand-rolling effects for data.
useEffect fetches twice, useSWR fetches onceReact 18 StrictMode double-mounts components in dev, so the standard useEffect(fn, []) fetch runs twice โ the famous "why does my fetch fire twice" question. Measured side by side in the same StrictMode app:
| pattern | fetches observed (dev, StrictMode) |
|---|---|
useEffect(() => { fetch(โฆ) }, []) | 2 โ both mounts fetch |
useSWR(url, fetcher) | 1 โ second mount deduped within dedupingInterval |
SWR's request dedup window (default 2s) absorbs the double mount. No useRef guard, no ignore flag in cleanup.
Dedup isn't just a StrictMode fix โ it's the reason you can call useSWR wherever the data is needed instead of lifting state:
function Badge() {
const { data } = useSWR(`${API}/products?limit=3`, fetcher);
return {data?.length ?? 'โฆ'};
}
// render five of them, side by side:
<> >
Measured: 5 mounted components, 1 network request, all five rendered "3". Same key โ same in-flight promise โ one fetch, five subscribers.
error is undefined on an HTTP 500 โ and what renders insteadThe most-asked SWR question, reproduced deliberately. fetch does not reject on HTTP error statuses โ so if your fetcher just does r.json(), a 500 is a successful fetch of an error body:
// THE BUG: this fetcher never throws โ SWR can't see the failure.
const fetcher = url => fetch(url).then(r => r.json());
const { data, error } = useSWR(`${API}/products?limit=5&mock_status=500`, fetcher);
Measured with a forced 500: error stayed undefined, isLoading went false, and data became the error body โ our UI literally rendered {"error":"simulated 500 error (mock_status)"} as if it were products. The fix is three lines, and it's what activates everything in SWR's error machinery (the error return, retries, onError callbacks):
const fetcher = async url => {
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status); // โ SWR only sees thrown errors
return r.json();
};
Measured with the throwing fetcher: error.message === "HTTP 500", data stayed undefined, error branch rendered. ?mock_status=<code> forces any status on any endpoint, so you can render every error branch on purpose.
const { data, error, isLoading } = useSWR(`${API}/products?${params}`, fetcher);
if (isLoading) return ;
if (error) return ;
if (data.length === 0) return ;
return ;
| params | what we observed |
|---|---|
limit=5&mock_delay=2000 | isLoading true during the real 2-second delay ("loading" on screen), then "data: 5" |
limit=5&mock_status=500 | "error: HTTP 500" branch rendered |
category=nonexistent-cat | [] โ "empty" (the state everyone forgets to design) |
More recipes: testing loading & error states.
Type fast and a slow early response lands after a fast later one. We forced it with a real 3-second mock_delay on the first query. First, the naive hand-rolled version:
// Naive: whichever response lands LAST wins โ even for the older query.
search('life', 3000); // slow, launched first
search('way', 0); // fast, launched 300 ms later โ what the user typed
Measured (naive): final UI showed results for life โ the stale query overwrote the fresh one 3 seconds after the user had moved on. Now the SWR version, where the query is simply part of the key:
const [q, setQ] = useState('');
const { data } = useSWR(`${API}/products?q=${q}`, fetcher);
Measured (SWR): UI showed way: 7 within half a second and still way: 7 after the stale life response landed. The slow response was filed under its own cache key โ the component only reads the key it's currently rendering, so the race can't clobber it. Two fetches, zero AbortControllers, zero cleanup code. (Add keepPreviousData: true if you want the old list to linger instead of a flash of loading.)
?mock_seq= serves a deterministic status sequence per key โ so "fails twice, then succeeds" is reproducible instead of hoped-for. SWR retries errors out of the box (once your fetcher throws โ ยง5):
const { data, error } = useSWR(
`${API}/products?limit=3&mock_seq=503,503,200&mock_seq_key=run-42`,
throwingFetcher,
{ errorRetryInterval: 300, errorRetryCount: 5 });
Measured: exactly 3 fetches โ 503, 503, 200 โ recovered in 1.9s with no code beyond the config. The observed gaps (336 ms, then 1518 ms) show SWR's exponential backoff with jitter doing its thing. Use a fresh mock_seq_key per run so the sequence restarts. Chaos and jitter variants (?mock_chaos=, ?mock_jitter=) are in the docs.
refreshInterval gotcha: polling silently throttled by dedupSet refreshInterval: 500 and count what actually leaves the browser:
| config | fetches in 3.2s (measured) |
|---|---|
{ refreshInterval: 500 } | 2 โ each tick fires, but lands inside the default 2-second dedupingInterval and gets swallowed |
{ refreshInterval: 500, dedupingInterval: 0 } | 6 โ the rate you asked for |
If your "live" dashboard updates every ~2 seconds no matter what you set, this is why: polling faster than dedupingInterval requires lowering dedupingInterval too. Pair it with a mutating endpoint (writes persist here โ POST something and watch the next poll pick it up) to see the loop actually work.
useSWRInfinite + X-Total-Countconst getKey = (idx, prev) => prev && prev.length === 0 ? null
: `${API}/products?page=${idx + 1}&limit=10`;
const fetcher = async url => {
const r = await fetch(url);
setTotal(Number(r.headers.get('X-Total-Count'))); // exposed via CORS
return r.json();
};
const { data, setSize } = useSWRInfinite(getKey, fetcher, { revalidateFirstPage: false });
const items = (data ?? []).flat();
Measured: "10 of 30" after mount, "30 of 30" after two Load-more clicks โ 3 fetches, zero duplicate ids, button disabled at the end. The header tells you when to stop; revalidateFirstPage: false stops the extra page-1 refetch on every setSize. json-server aliases (_page/_limit) work too.
Optimistic UI is easy to demo when the write succeeds. The part worth testing is the rollback โ so we forced the POST to fail with a real 500 (which this API treats as "server died before processing": the write is not applied, matching what a real outage does):
await mutate(key, async current => {
const r = await fetch(`${API}/products?mock_status=500`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify(newItem) });
if (!r.ok) throw new Error('HTTP ' + r.status);
return [...current, await r.json()];
}, {
optimisticData: current => [...current, newItem],
rollbackOnError: true,
revalidate: false,
});
Measured: the list rendered 3 rows โ 4 rows (optimistic item visible) โ back to 3 rows when the 500 landed. And because the failed write really wasn't applied server-side, a revalidate afterwards agrees with the rolled-back UI โ no ghost row, which is exactly the bug this pattern exists to prevent. Drop the mock_status param and the same code path persists the row for real.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "Content-Type: application/json" \
-d '{"preset": "ecommerce"}'
The response contains your project URL and admin key. Presets: blog, ecommerce, saas, payments โ or define your own resources and field types, import an OpenAPI spec / db.json / CSV / Postman collection / HAR, and swap the base URL in the snippets above. Ship day is an env-var change: VITE_API_URL=https://api.yourcompany.com.
There's a mock JWT login too โ any email/password gets a real signed token from /auth/login, and /auth/me reads it back ({email:"dev@example.com"} against the demo returns that seeded user). Build the whole auth UI with a useSWR('/auth/me') before an auth backend exists โ guide.
POST/PATCH/DELETE actually stick (unlike JSONPlaceholder's write theater) โ so mutate()-then-revalidate flows can be verified against reality, not just optimism.GET /m/<project>/types.ts generates interfaces from your live schema (?format=zod for Zod schemas) โ type your fetchers for free.beforeEach โ deterministic Playwright/Cypress runs (guide).?mock_ratelimit=5 gives you real 429s with Retry-After โ see how your SWR error handling behaves under throttling./m/<project>/graphql if you're pairing SWR with a GraphQL fetcher.| Tool | Good at | Where this differs |
|---|---|---|
| TanStack Query | Richer cache control, mutations API, devtools | Different client library, same problem shape โ and the same need for a backend that can fail on demand. We have a TanStack Query guide · TanStack Query useQuery, measured too; the mock API side is identical. |
| MSW (Mock Service Worker) | In-process request interception for unit tests; no network at all | MSW mocks live inside each test runner. Mockbird is a real hosted URL โ the same mock serves your browser, CI, a StackBlitz repro, and a teammate's machine with zero setup. Use MSW for unit tests, a hosted mock for everything shared. |
| json-server | Local full-featured fake REST; huge ecosystem | Needs Node running on every machine that wants the API. Mockbird speaks the same conventions (_page, _limit, db.json import/export) but is hosted โ and adds failure scripting (mock_seq/mock_status), auth simulation, snapshots. |
| JSONPlaceholder | Instant, zero-setup, famous | Fixed dataset, writes are faked โ an optimistic-mutate rollback can't be tested against write theater. Fine for a first useSWR tutorial; not for testing real UI states. |
Rolling your own with React 19's use() hook instead of SWR? We measured why the naive version silently loops (110 fetches in 5 s) and what actually fixes it: React use() + fetch, measured.
Bias disclosure: this comparison is written by the Mockbird side. The measurements above, though, are just measurements โ rerun them in your own Chrome with the snippets as written.