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.
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.
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.
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.)
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.)
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:
| params | what 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.
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.
?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.
X-Total-Countconst 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.
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.
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.
POST/PATCH/DELETE actually stick (unlike JSONPlaceholder's write theater) β build the optimistic-update UI and verify against reality.GET /m/<project>/types.ts generates interfaces from your live schema (?format=zod for Zod schemas).beforeEach β deterministic Playwright/Cypress runs (guide)./m/<project>/graphql if you're trying signals with a GraphQL client.| Tool | Good at | Where this differs |
|---|---|---|
| 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, 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-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 auth simulation, failure injection, snapshots. |
| JSONPlaceholder | Instant, zero-setup, famous | Fixed 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.