Honest split first: MSW plus a per-test QueryClient with retry: false is the right default for unit-testing components and hooks built on TanStack Query. That's also what the TanStack docs themselves recommend, and this page is not going to pretend otherwise. But two default behaviors of QueryClient quietly break naive tests โ we measured both โ and MSW's interception stops at the edge of your test process, which matters the moment the consumer of the mock isn't your test runner.
Everything below was verified the week of writing (Aug 2026: @tanstack/react-query 5.102, React 19.2, Vitest 4.1, MSW 2.15, @testing-library/react 16.3, jsdom 30, Node 22) โ every snippet was actually run, in a 10-test suite that passes.
A fresh QueryClient() with no options retries every failed query 3 times with exponential backoff before it ever reports isError. We counted it against a real endpoint forced to fail:
// default QueryClient, queryFn hits a URL that always 500s
const queryClient = new QueryClient() // no options
// ...
await waitFor(() => expect(result.current.isError).toBe(true), { timeout: 25000 })
// measured: 4 attempts (1 + 3 retries), 7.6 seconds of backoff before isError
Four attempts, 7.6 seconds โ long enough to blow through Testing Library's default waitFor timeout (1s) and make the failure look like "isError never becomes true". The fix is one line in your test client:
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
// same error test, measured: 161ms
QueryClient bleeds cache between testsThe client is the cache. Reuse one across tests and test B can read test A's data for the same queryKey without ever hitting your stub โ pass or fail depends on execution order. Create the client inside the test (or a fresh one in beforeEach) and wrap with the provider:
function makeWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return { queryClient, wrapper }
}
const { result } = renderHook(() => useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
}), { wrapper: makeWrapper().wrapper })
Praise where due: msw/node's setupServer intercepted useQuery's fetch perfectly in our suite, and with onUnhandledRequest: 'error' an unlisted path fails loudly instead of silently passing through (both verified). But the interception lives in your test process's runtime. We stubbed a URL with MSW and fetched the same URL from a child process in the same test:
// in the test process: MSW handler returns [{ id: 999, name: 'Stubbed Product' }]
// child process, same URL:
execFileSync(process.execPath, ['-e',
`fetch('${API}/products').then(r => r.json()).then(d => console.log(d.length))`])
// prints 20 โ the real records. The stub doesn't exist outside the test runner.
That boundary is where "mock React Query" questions actually come from: the Vite dev server you're demoing from, a Playwright test against a deployed preview, a React Native app, a teammate's machine, a CI job โ none of them can see handlers registered in your Vitest process. (In-browser MSW via Service Worker covers the dev-server case, at the cost of shipping worker setup and handler code with your app.)
Mockbird gives you a stateful mock REST API at a real URL โ one curl, no signup:
curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H 'content-type: application/json' \
-d '{"preset":"ecommerce"}'
# โ { "id": "abc123...", "adminKey": "...", "baseUrl": ".../m/abc123..." }
Seeded realistic data, full CRUD that persists, filtering/sorting/pagination, CORS on by default. Every consumer โ jsdom test, browser, Playwright, phone, teammate โ sees the same mock because it's just HTTP. Here's what that buys a TanStack Query test suite specifically; each example below is lifted from the verified suite.
TanStack Query's retry is a headline feature, and a static stub can only prove the failure half. ?mock_seq=503,503,200 serves exactly that sequence โ fail, fail, then the real data:
const url = `${API}/products?limit=2&mock_seq=503,503,200&mock_seq_key=${testId}`
const { result } = renderHook(() => useQuery({
queryKey: ['flaky'],
queryFn: () => getJson(url),
retry: 2, retryDelay: 50,
}), { wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
// verified: exactly 3 attempts, then data.length === 2 โ recovery, not just error
mock_seq_key gives each test (or parallel worker) its own sequence counter, so runs never interfere โ see the docs for the full parameter table.
?mock_delay=1500 holds the response for 1.5s of wall-clock time. We asserted isPending === true at the 800ms mark โ a skeleton screen you can actually see, in tests or in a browser demo:
useQuery({ queryKey: ['slow'], queryFn: () => getJson(`${API}/products?mock_delay=1500`) })
// at t=800ms: result.current.isPending === true (verified)
getJson(`${API}/products?mock_status=503`) // โ real HTTP 503 over the network
// with retry:false, isError in 161ms (measured)
useInfiniteQuery against real paginationInfinite scroll is miserable to hand-stub: every page needs its own handler and the math needs to agree with itself. Against a real paginated endpoint it's just data:
useInfiniteQuery({
queryKey: ['inf'],
initialPageParam: 1,
queryFn: async ({ pageParam }) => {
const res = await fetch(`${API}/products?_page=${pageParam}&_limit=12`)
return { items: await res.json(),
total: Number(res.headers.get('X-Total-Count')), page: pageParam }
},
getNextPageParam: (last) => last.page * 12 < last.total ? last.page + 1 : undefined,
})
// verified: page 1 = 12 items, fetchNextPage() โ page 2, zero overlapping ids,
// X-Total-Count: 30, hasNextPage true
invalidateQueries against an actual storeThis is the structural difference. MSW handlers answer with whatever you scripted; if you want a created record to show up in the next list fetch, you write and maintain that state by hand. Mockbird's writes persist server-side, so the invalidation round-trip is real:
const create = useMutation({
mutationFn: (body) => postJson(`${API}/products`, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['prods'] }),
})
create.mutate({ name: 'RQGuideTest widget', price: 9.99 })
// verified: invalidateQueries triggers a refetch that really contains the record,
// GET /products/<id> returns it (server state, not cache), DELETE โ later GET 404s
| You want to test | In-process approach | Hosted approach |
|---|---|---|
| Success render | MSW handler per endpoint | seeded endpoint exists already |
isError path | HttpResponse with status 500 | ?mock_status=503 (or any code) |
| Retry recovery | stateful handler counting calls (hand-rolled) | ?mock_seq=503,503,200 โ deterministic |
| Loading/skeleton | delay() in handler code | ?mock_delay=1500 on the URL |
useInfiniteQuery | one handler per page + math | ?_page=&_limit= + X-Total-Count |
| Mutation โ invalidation round-trip | hand-written handler state | writes persist; refetch is genuinely fresh |
| Deterministic dataset per scenario | fixture files | snapshots โ pin with ?mock_snapshot= |
| Flaky-network resilience | โ | ?mock_chaos=0.3 random 5xx |
curl 'https://mockbird.mockbird.workers.dev/m/demo/products?_page=2&_limit=5'
curl -i 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_status=503'
curl 'https://mockbird.mockbird.workers.dev/m/demo/products?mock_seq=503,503,200&mock_seq_key=me'
All against the shared demo project (resets daily). Or create your own with one click: open the dashboard with an e-commerce preset.
retry: false and a fresh client per test. Point the dev server, Playwright-against-preview runs, React Native builds and teammates at a Mockbird base URL via VITE_API_URL. Component code doesn't change.server.listen({ onUnhandledRequest: 'bypass' }) pass everything else through to the hosted mock.GET /m/<project>/db.json ejects your dataset any time; msw.js export generates MSW handlers from your live mock schema if you want to go back in-process.| MSW + test QueryClient | Mockbird | |
|---|---|---|
| What it is | npm library + config discipline | free hosted service |
| Reachable from | the process (or browser tab) that registered handlers | anything with HTTP: jsdom, browser, Playwright, mobile, CI, teammates |
| Works offline / zero latency | yes | no โ it's a real network call |
| Unmatched request | configurable, loud with onUnhandledRequest:'error' (verified) | n/a โ real endpoints answer real queries |
| Stateful CRUD | hand-rolled handler state | default โ writes persist |
| Retry recovery testing | call-counting handler you maintain | ?mock_seq, deterministic |
| Real durations (loading, timeout) | delay() utility | ?mock_delay/?mock_jitter, wall-clock |
| Arbitrary custom payloads | anything you can write in JS | schema-driven + custom routes for fixed payloads |
| Request assertions | per-test, precise | request inspector (last 50, headers/body) |
| Request cap | none | 10,000/project/day |
Written by the Mockbird maker โ bias disclosed. Where MSW and TanStack Query's own tooling genuinely win: offline, zero-latency, arbitrary JS in handlers, and the official docs' testing guidance is good โ retry: false plus a fresh client per test solves most flakiness people blame on the library. For fast unit tests that should stay your default. When the thing you need is a URL โ for a dev-server demo, Playwright against a preview, a phone, a teammate, or CI โ that's us.
Full API reference in the docs. More guides: mock API for React ยท mock APIs in Vitest ยท mock APIs in Jest ยท MSW alternative ยท cursor pagination ยท deterministic test data ยท testing loading & error states. Create your API โ