โ† All guides

Mock a REST API for SWR โ€” data fetching without the traps, every claim measured

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.

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, or anywhere else โ€” no proxy config. When you want your own schema it's one curl or one click โ€” see ยง12.

โšก 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.

2. The storm SWR actually kills: 75 fetches in 4 seconds vs exactly 1

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.

3. StrictMode: useEffect fetches twice, useSWR fetches once

React 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:

patternfetches 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.

4. Five components, one request

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.

5. Why error is undefined on an HTTP 500 โ€” and what renders instead

The 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.

6. All four UI states โ€” each one reachable on demand

const { data, error, isLoading } = useSWR(`${API}/products?${params}`, fetcher);
if (isLoading)             return ;
if (error)                 return ;
if (data.length === 0)     return ;
return ;
paramswhat we observed
limit=5&mock_delay=2000isLoading 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.

7. The stale-search race โ€” SWR wins it with zero AbortControllers

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.)

8. Built-in retry vs a scripted failure sequence

?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.

9. The refreshInterval gotcha: polling silently throttled by dedup

Set refreshInterval: 500 and count what actually leaves the browser:

configfetches 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.

10. Real pagination with useSWRInfinite + X-Total-Count

const 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.

11. Optimistic update with a real rollback

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.

12. 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"}'

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.

13. Beyond this guide

14. Honest comparison

ToolGood atWhere this differs
TanStack QueryRicher cache control, mutations API, devtoolsDifferent 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 allMSW 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-serverLocal full-featured fake REST; huge ecosystemNeeds 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.
JSONPlaceholderInstant, zero-setup, famousFixed 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.