โ† All guides

Testing TanStack Query (React Query) โ€” the two config gotchas, the MSW boundary, and when the mock needs to be a real URL

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.

The three things that send people searching

1. Your error test isn't broken โ€” the default client retries 3 times first

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

2. A shared QueryClient bleeds cache between tests

The 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 })

3. MSW is great โ€” inside the test process. Nothing else sees your handlers

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

The hosted half: a mock API on a real URL

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.

Retry logic you can watch recover โ€” not just fail

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.

Loading states where real time passes

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

Error states without registering a stub

getJson(`${API}/products?mock_status=503`)   // โ†’ real HTTP 503 over the network
// with retry:false, isError in 161ms (measured)

useInfiniteQuery against real pagination

Infinite 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

Mutations + invalidateQueries against an actual store

This 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

TanStack Query testing concepts โ†’ Mockbird

You want to testIn-process approachHosted approach
Success renderMSW handler per endpointseeded endpoint exists already
isError pathHttpResponse with status 500?mock_status=503 (or any code)
Retry recoverystateful handler counting calls (hand-rolled)?mock_seq=503,503,200 โ€” deterministic
Loading/skeletondelay() in handler code?mock_delay=1500 on the URL
useInfiniteQueryone handler per page + math?_page=&_limit= + X-Total-Count
Mutation โ†’ invalidation round-triphand-written handler statewrites persist; refetch is genuinely fresh
Deterministic dataset per scenariofixture filessnapshots โ€” pin with ?mock_snapshot=
Flaky-network resilienceโ€”?mock_chaos=0.3 random 5xx

Try it in 10 seconds (shared demo, no setup)

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.

Or use both โ€” they compose

Honest comparison

MSW + test QueryClientMockbird
What it isnpm library + config disciplinefree hosted service
Reachable fromthe process (or browser tab) that registered handlersanything with HTTP: jsdom, browser, Playwright, mobile, CI, teammates
Works offline / zero latencyyesno โ€” it's a real network call
Unmatched requestconfigurable, loud with onUnhandledRequest:'error' (verified)n/a โ€” real endpoints answer real queries
Stateful CRUDhand-rolled handler statedefault โ€” writes persist
Retry recovery testingcall-counting handler you maintain?mock_seq, deterministic
Real durations (loading, timeout)delay() utility?mock_delay/?mock_jitter, wall-clock
Arbitrary custom payloadsanything you can write in JSschema-driven + custom routes for fixed payloads
Request assertionsper-test, preciserequest inspector (last 50, headers/body)
Request capnone10,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 โ†’