Zustand is the state library React developers reach for when Redux feels like ceremony: a store is one create() call, an action is just an async function that calls set(). That simplicity is real โ and it means nothing stands between your fetch code and React's sharpest edges. No query cache, no dedupe, no cancellation, no selector safety net. The patterns below all look like the obvious way to write them; four of them are a production incident.
Everything here was reproduced and measured in a real Chrome on zustand 5.0.15 + React 19.3 (Vite, client-side) with an instrumented window.fetch and render counters before publishing. The numbers โ a blank page after exactly 55 renders, 201 fetches in 5 seconds, a page-2 header sitting on page-1 rows โ 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, CI โ anywhere. When you want your own schema it's one curl or one click โ see ยง13.
An async action plus a useEffect โ the shape the docs teach:
const useProducts = create((set) => ({
items: [], loading: false,
load: async () => {
set({ loading: true })
const r = await fetch(`${API}/products`)
set({ items: await r.json(), loading: false })
},
}))
function List() {
const items = useProducts(s => s.items)
const load = useProducts(s => s.load)
useEffect(() => { load() }, [load])
return <ul>{items.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
Measured: 1 fetch, 2 renders, 20 rows (the demo API's default page size; X-Total-Count: 30 tells you the rest). Everything below breaks this baseline in a different way.
Delete the useEffect and "just call it" โ a refactor we've seen in real diffs:
function List() {
const { items, load } = useProducts(useShallow(s => ({ items: s.items, load: s.load })))
load() // โ no effect, just load it
return <ul>{items.map(p => <li key={p.id}>{p.name}</li>)}</ul>
}
Measured: 77 fetches in the first 2 seconds, 201 by 5 seconds, renders tracking fetches 1:1, and it never stops. The console shows exactly one warning โ React's "Cannot update a component while rendering a different component" โ and then goes quiet while the storm continues.
The mechanism is a zustand v5 detail worth knowing: set() merges into a new state object and always notifies subscribers โ there's no "nothing changed" bailout for a merge, even set({ loading: true }) when loading is already true. So: render โ load() โ set() โ notify โ re-render โ load() โ โฆ Each lap fires a real network request. Against a rate-limited or metered API, this is the loop that pages you. (Compare the same shape in React 19's use() and Svelte 5's $effect โ every framework has one; zustand's runs at full speed with one warning.)
The advice "select only what you need" usually gets written like this:
const { items, n } = useProducts(s => ({ items: s.items, n: s.items.length }))
In zustand v4 folklore this was a performance concern. Measured on v5, it is a crash: the selector returns a fresh object every call, useSyncExternalStore sees a changed snapshot every time, and after exactly 55 renders React gives up:
The result of getSnapshot should be cached to avoid an infinite loop
Uncaught Error: Maximum update depth exceeded.
The component unmounts and the page goes blank. The data was fine โ the network log shows 1 successful fetch โ the selector alone killed the app.
import { useShallow } from 'zustand/react/shallow'
const { items, n } = useProducts(useShallow(s => ({ items: s.items, n: s.items.length })))
Measured on the same store, same data: 2 renders, 20 rows, clean console. useShallow memoizes the selector result by shallow equality, so a new-but-equal object doesn't count as a new snapshot. Rule of thumb: single-value selectors (s => s.items) are always safe; the moment a selector returns an object or array literal, wrap it.
The baseline from ยง2, wrapped in <StrictMode> (i.e. every fresh Vite/CRA app in dev): 2 fetches โ React double-invokes the mount effect on purpose. Harmless here, misleading in your network tab, and double-billed against any metered API. Because zustand state lives outside React, the fix is one line of store state, not an effect-cleanup dance:
load: async () => {
if (get().inflight) return // โ guard lives in the store
set({ inflight: true })
const r = await fetch(`${API}/products`)
set({ items: await r.json(), inflight: false })
},
Measured under StrictMode: exactly 1 fetch, list renders normally.
A pager action, written the natural way โ update the page immediately (snappy UI), fill the items in when the response lands:
loadPage: async (p) => {
set({ page: p }) // header updates now
const r = await fetch(`${API}/products?_page=${p}&_limit=5`)
set({ items: await r.json() }) // whoever resolves LAST wins
},
To reproduce the race deterministically, the mock decides who's slow โ page 1 answers in 800 ms, page 2 in 100 ms (mock_delay is per-request):
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=1&_limit=3&mock_delay=800"
curl "https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=5&mock_delay=100"
Click page 1, then page 2 150 ms later. Measured: 2 fetches, 0 aborts, header reads "page 2" โ and the list shows page 1's three rows, starting at id 1. Page 2's response arrived first and was silently overwritten when the stale page-1 response resolved. No warning, no error; the store happily stored history out of order. This is the same last-to-resolve-wins shape we've measured in Pinia and Vue watchEffect โ except here even the header lies, because you set it eagerly.
Zustand's superpower for this bug: the controller can live next to the data it protects, no refs, no effect cleanup:
loadPage: async (p) => {
get().ctrl?.abort() // cancel the previous flight
const ctrl = new AbortController()
set({ page: p, ctrl })
try {
const r = await fetch(`${API}/products?_page=${p}&_limit=5`, { signal: ctrl.signal })
set({ items: await r.json() })
} catch (e) { if (e.name !== 'AbortError') throw e }
},
Same click sequence, measured: exactly 1 abort, header "page 2", and the rows are page 2's (first id 6). The stale request died on the network instead of landing late. A request-id guard (if (get().page !== p) return before the final set) also prevents the clobber โ but only the abort saves the bandwidth.
No res.ok check โ the JSON parses fine, so nothing throws where you'd expect:
load: async () => {
const r = await fetch(`${API}/products?mock_status=500`)
set({ items: await r.json() }) // 500 body parses fine โ it's just not an array
},
Measured: items becomes the error object, the component throws Uncaught TypeError: items.map is not a function, and without an error boundary the page goes blank. The store outlives the component, too โ the poisoned items sits there for the next subscriber. Drill every branch of your res.ok handling against real statuses before shipping:
curl -i "https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=500" # any code: 401, 403, 429โฆ
"Retry on 5xx" code paths usually go to production untested because you can't make prod fail twice on demand. mock_seq serves a deterministic status script โ first request 503, second 503, third the real data:
const r = await fetch(`${API}/products?limit=3&mock_seq=503,503,200&mock_seq_key=run1`)
Measured with a plain for-loop retry in the store action: exactly 3 fetches, then 3 rows render. Each response carries x-mockbird-seq: 1/3 โฆ 3/3 so every attempt is assertable; the sequence sticks on its last entry per key, so use a fresh mock_seq_key (or mock_seq_reset=1) per test run.
A worry we can retire with a number. This action commits its result in three separate writes:
const items = await r.json()
set({ items })
set({ count: items.length })
set({ loadedAt: Date.now() })
Measured: a component selecting all three fields renders twice total โ mount + one update. React 18+ batches the whole microtask continuation, so three store writes after an await collapse into a single commit. Write clear actions; don't contort them into one mega-set() for performance.
Seeded stores mean your "No products yet" branch ships untested. Pin a request to an empty snapshot โ live data untouched:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?mock_snapshot=empty" # โ []
Measured in the harness: the empty-state div renders (and note the subtlety โ initialize items: null to distinguish "loading" from "loaded zero", or your spinner and your empty state collapse into one).
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "content-type: application/json" \
-d '{"preset": "ecommerce"}'
That returns a live base URL with seeded products, orders, customers, reviews โ full CRUD, filters (?price_gte=100), pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag used above (mock_status, mock_delay, mock_seq, envelope reshaping, chaos). Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.
Written by the Mockbird maker โ bias disclosed. Zustand comes out of these measurements exactly as advertised: a minimal store, not a data-fetching layer โ every trap above is a fetching concern zustand explicitly leaves to you (their docs point at TanStack Query for serious server state, and that's fair advice โ see our TanStack Query guide). What the measurements add: the object-selector mistake is a hard crash in v5, not the v4-era perf nit most advice still describes; the render-loop storm runs at 200+ requests per 5s with a single warning; and the naive-action race lies in the header, not just the list. If you keep fetch logic in zustand actions โ plenty of production apps do โ the AbortController-in-the-store pattern (ยง8) and the in-flight guard (ยง6) are cheap insurance. Measurements taken Sep 24, 2026 on zustand 5.0.15, React 19.3, Vite 5, client-side rendering. Related: mocking for React, React 19 use(), measured, Jotai async atoms, measured, MobX, measured, Redux createAsyncThunk, measured (the opposite failure mode โ silent freezes over correct data) (the race you can't lose โ and the free abort signal), SWR, RTK Query, Pinia, measured (the same bugs in Vue clothing).