← All guides

Mock a REST API for Preact β€” fetching with hooks and signals, without the traps

Preact gives you two ways to fetch data β€” classic hooks (useEffect + useState) and @preact/signals β€” and each has its own way of going badly wrong. Fetch in the component body and you get a self-perpetuating render storm. Write a "retry while empty" effect() that reads a signal it also writes, and signals kills your app with a verbatim Cycle detected error. And if you arrived from React wondering why your fetch fires twice in dev β€” in Preact it doesn't, and we counted.

This guide pairs a plain client-side Preact app (Vite, no framework wrapper) with a hosted mock API and walks through each trap β€” every one reproduced and measured in a real Chrome on Preact 10.29.8 + @preact/signals 2.11.2 before publishing. All numbers below (76 fetches, 101 fetches, exactly 1 fetch) are observed counts from an instrumented window.fetch, 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, or anywhere else β€” no proxy config. When you want your own schema it's one curl or one click β€” see Β§10.

⚑ 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 render-body fetch storm: 76 requests in 4 seconds, forever

The oldest trap in any VDOM framework, and Preact is no exception. Fetch in the component body, set state when it lands, and every response schedules the re-render that fires the next fetch:

// THE BUG: fetch + setState in the component body.
function ProductList() {
  const [items, setItems] = useState([]);
  fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3')
    .then(r => r.json())
    .then(d => setItems(d));   // setState β†’ re-render β†’ body runs again β†’ fetch again…
  return <p>got {items.length}</p>;
}

Measured: with a render counter and an instrumented fetch, this component produced 76 renders and 76 network requests in the first 4 seconds β€” and unlike the signals cycle below, nothing ever stops it. The rate is bounded only by your network round-trip (~19 requests/sec against a fast mock; against a slow API it just loops slower). No error, no warning β€” your API bill is the error message. The fix is the next section.

3. Fetch-on-mount fires exactly once β€” no StrictMode double-fetch

The correct hooks version, and a data point for React migrants:

import { useState, useEffect } from 'preact/hooks';

function ProductList() {
  const [items, setItems] = useState([]);
  useEffect(() => {
    fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=5')
      .then(r => r.json())
      .then(setItems);
  }, []);   // empty deps: runs after first render only
  return <ul>{items.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

Measured: exactly 1 fetch, 5 <li> rendered. If you're coming from React 18+, note what didn't happen: no development-mode double-invocation, no second request to explain in the network tab. Preact core has no StrictMode mount-unmount-remount cycle, so an empty-deps effect really does mean one request in dev. (This also means Preact won't surface a missing-cleanup bug for you in dev the way React does β€” the abort pattern in Β§6 matters either way.)

4. The signals trap: "retry while empty" β†’ verbatim Cycle detected after 101 fetches

@preact/signals' effect() re-runs whenever a signal it read changes. The trap is an effect that reads a signal and writes it β€” which is exactly what an innocent "reset, then retry while empty" looks like:

import { signal, effect } from '@preact/signals';

const data = signal(null);

// THE BUG: the effect READS data.value and WRITES data.value.
effect(() => {
  if (!data.value || data.value.length === 0) {
    data.value = [];                    // reset β†’ effect reran itself right here
    fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3')
      .then(r => r.json())
      .then(d => { data.value = d; });
  }
});

Measured: the synchronous data.value = [] re-triggers the effect immediately β€” before any fetch has a chance to resolve β€” and signals gives up with an uncaught, verbatim:

Uncaught Error: Cycle detected

By the time it crashed, 101 fetch calls had been fired. Two things worth knowing: the error is thrown from the effect, not logged β€” so a try/catch around the effect(...) registration does not catch it (ours didn't; it surfaced as a window error event). And unlike the render storm in Β§2, at least signals stops.

The fix is .peek() β€” read the current value without subscribing:

effect(() => {
  if (!data.peek() || data.peek().length === 0) {   // .peek() = no subscription
    data.value = [];
    fetch('https://mockbird.mockbird.workers.dev/m/demo/products?limit=3')
      .then(r => r.json())
      .then(d => { data.value = d; });
  }
});

Measured: exactly 1 fetch, no errors. With no subscription the effect runs once; the async write settles the signal without re-triggering. (If you want the effect to react to a different signal β€” say, a query β€” read that one with .value and keep .peek() for anything the effect writes.)

5. All four UI states β€” each one reachable on demand

Loading, error, empty, data. Your real API in dev shows you exactly one of these. The mock can serve all four, deterministically, via query params:

function States({ params }) {
  const [state, setState] = useState({ kind: 'loading' });
  useEffect(() => {
    fetch(`https://mockbird.mockbird.workers.dev/m/demo/products?${params}`)
      .then(async r => {
        if (!r.ok) { setState({ kind: 'error', code: r.status }); return; }
        const d = await r.json();
        setState(d.length ? { kind: 'data', items: d } : { kind: 'empty' });
      })
      .catch(() => setState({ kind: 'error', code: 0 }));
  }, [params]);
  if (state.kind === 'loading') return <p>loading</p>;
  if (state.kind === 'error')   return <p>error {state.code}</p>;
  if (state.kind === 'empty')   return <p>nothing here yet</p>;
  return <p>{state.items.length} products</p>;
}

Measured with these params, in a real browser:

paramswhat we observed
limit=5&mock_delay=2000"loading" on screen during the real 2-second delay, then "data 5"
limit=5&mock_status=500"error 500" branch rendered
category=nonexistent-cat[] β†’ "nothing here yet" (the state everyone forgets to design)

?mock_delay=<ms> and ?mock_status=<code> work on every endpoint β€” skeleton screens and error boundaries become things you can look at on purpose. More recipes: testing loading & error states.

6. The stale-search race β€” measured lost, then won with AbortController

Type fast and a slow early response can land after a fast later one, overwriting fresh results with stale ones. We forced the race with a real 3-second mock_delay on the first query:

// Naive: whichever response lands LAST wins β€” even if it's the older query.
function search(term, delay = 0) {
  fetch(`${API}/products?q=${term}&mock_delay=${delay}`)
    .then(r => r.json())
    .then(d => { results.value = { term, n: d.length }; });
}
search('life', 3000);  // slow, launched first
search('way', 0);      // fast, launched 300 ms later β€” what the user actually typed

Measured (naive): the UI briefly showed results for way, then the slow life response landed ~3s later and overwrote them β€” final state was the stale query. Measured (fixed): same timeline with an AbortController: final state way, and exactly one AbortError caught:

let ctrl = null;
function search(term, delay = 0) {
  if (ctrl) ctrl.abort();          // kill the in-flight request first
  ctrl = new AbortController();
  fetch(`${API}/products?q=${term}&mock_delay=${delay}`, { signal: ctrl.signal })
    .then(r => r.json())
    .then(d => { results.value = { term, n: d.length }; })
    .catch(e => { if (e.name !== 'AbortError') throw e; });
}

In a hooks component the idiomatic home for the abort is the useEffect cleanup: create the controller inside the effect, return () => ctrl.abort(), and every re-run (and unmount) cancels the previous request.

7. Retry logic against deterministic failures

?mock_seq= scripts the exact status sequence, per key β€” so "fails twice, then succeeds" is reproducible instead of hoped-for:

async function retryFetch(url, tries = 5) {
  for (let i = 0; i < tries; i++) {
    const r = await fetch(url);
    if (r.ok) return r.json();
    await new Promise(res => setTimeout(res, 300 * (i + 1)));  // linear backoff
  }
  throw new Error('gave up');
}
retryFetch(`${API}/products?limit=2&mock_seq=503,503,200&mock_seq_key=my-test-1`);

Measured: statuses observed in order β€” [503, 503, 200] β€” then 2 products returned. Use a fresh mock_seq_key per test run so the sequence starts from the beginning. Chaos and jitter variants (?mock_chaos=, ?mock_jitter=) are in the docs.

8. Real pagination with X-Total-Count

const r = await fetch(`${API}/products?_page=${page}&_limit=10`);
const total = Number(r.headers.get('X-Total-Count'));   // exposed via CORS
const items = await r.json();
setItems(prev => [...prev, ...items]);   // append for infinite scroll

Measured: "have 10 of 30" after the first page, "have 30 of 30" after three β€” 3 fetches, no duplicates, and the header tells you when to hide the Load-more button. json-server-style aliases (_page/_limit/_sort) and page/limit/sortBy/order both work.

9. A mock JWT login flow

The demo issues real signed JWTs for any email/password, and /auth/me reads the token back β€” enough to build the whole auth UI before an auth backend exists:

const token = signal(localStorage.getItem('token') || '');
const me = signal(null);

async function login() {
  const res = await fetch('https://mockbird.mockbird.workers.dev/m/demo/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: 'dev@example.com', password: 'anything' }),
  });
  token.value = (await res.json()).token;
  localStorage.setItem('token', token.value);
}

effect(() => {
  if (!token.value) { me.value = null; return; }
  fetch('https://mockbird.mockbird.workers.dev/m/demo/auth/me', {
    headers: { Authorization: `Bearer ${token.value}` },
  }).then(r => r.json()).then(d => { me.value = d.user ?? d; });
});

Note the shape: the effect reads token.value and writes me.value β€” two different signals, so no cycle (Β§4's rule in practice). Setting the token after login automatically triggers the /auth/me fetch. Token expiry, register-that-persists, and protecting the whole API behind the token are in the mock JWT auth guide.

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

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.

11. Beyond this guide

12. Honest comparison

ToolGood atWhere this differs
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, your 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 auth simulation, failure injection, snapshots.
JSONPlaceholderInstant, zero-setup, famousFixed dataset, fixed resources, writes are faked. Fine for a first fetch tutorial; not for testing real UI states.

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.