Testing React Query Hooks With a Fresh Cache
Custom hooks built on TanStack Query — useOrders(filters), useCustomer(id), useDashboardStats() — are where an application’s data-access logic accumulates: which endpoint, which parameters, how responses are shaped, which queries depend on which. They are best tested directly with renderHook, which is faster and more precise than rendering a component, provided each test gets its own cache. This guide covers the per-test client, a reusable wrapper, query key factories that keep tests and hooks in agreement, testing parameterised and dependent queries, asserting on what is cached, and the handful of options that turn a slow, leaky test into a fast, isolated one. It targets TanStack Query 5 with Vitest and sits under data fetching and cache testing.
Root Cause Analysis
The query cache is designed to be shared. In production that is the point — two components asking for the same customer trigger one request and share the result. In tests, a shared cache means the first test’s data is still there for the second, so a test for “loads the customer” can pass without making any request at all, and a test for “shows an error” can render stale success data instead.
The second problem is the defaults. Three retries with exponential backoff mean an error-path test waits several seconds before the hook reports failure; garbage-collection timers keep the process busy after tests complete; refetch-on-focus fires when jsdom dispatches focus events during interaction. None of these defaults is wrong for users and all of them make tests slow or unpredictable.
The third is key drift. A hook that builds its key inline — ['orders', filters] — and a test that seeds the cache with a hand-written key will disagree the moment someone reorders the key’s parts. The seed then lands in an unrelated cache entry, and the test silently tests a fetch it thought it had prevented.
Reproducible Setup
A key factory and two hooks: one parameterised, one dependent on the other’s result.
// src/orders/keys.ts
export const orderKeys = {
all: ['orders'] as const,
list: (filters: { status?: string; page?: number }) => [...orderKeys.all, 'list', filters] as const,
detail: (id: string) => [...orderKeys.all, 'detail', id] as const,
};
// src/orders/hooks.ts
import { useQuery } from '@tanstack/react-query';
import { orderKeys } from './keys';
export function useOrders(filters: { status?: string; page?: number }) {
return useQuery({
queryKey: orderKeys.list(filters),
queryFn: async () => {
const params = new URLSearchParams(Object.entries(filters).filter(([, v]) => v != null).map(([k, v]) => [k, String(v)]));
const res = await fetch(`/api/orders?${params}`);
if (!res.ok) throw new Error(`orders ${res.status}`);
return (await res.json()) as { items: Order[]; total: number };
},
});
}
export function useOrderCustomer(orderId: string | undefined) {
const order = useQuery({ queryKey: orderKeys.detail(orderId!), queryFn: () => getOrder(orderId!), enabled: !!orderId });
return useQuery({
queryKey: ['customers', order.data?.customerId],
queryFn: () => getCustomer(order.data!.customerId),
enabled: !!order.data?.customerId,
});
}
Implementation
Step 1 — Build a wrapper factory that creates a fresh client. Returning the client alongside the wrapper lets tests inspect or seed the cache.
// test/query-wrapper.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactNode } from 'react';
export function queryWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity, refetchOnWindowFocus: false } },
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
return { client, wrapper };
}
Step 2 — Test the hook with renderHook and wait for success. Assert on the returned data and on the request the hook made.
// src/orders/hooks.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
test('requests the filtered page and returns its items', async () => {
let seen: URLSearchParams | undefined;
server.use(http.get('/api/orders', ({ request }) => {
seen = new URL(request.url).searchParams;
return HttpResponse.json({ items: [{ id: 'o1' }], total: 1 });
}));
const { wrapper } = queryWrapper();
const { result } = renderHook(() => useOrders({ status: 'placed', page: 2 }), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ items: [{ id: 'o1' }], total: 1 });
expect(Object.fromEntries(seen!)).toEqual({ status: 'placed', page: '2' });
});
Asserting on the search parameters is what distinguishes this from a test that merely checks data arrives. The hook’s job includes building the request correctly — omitting undefined filters, stringifying numbers, using the right parameter names — and a handler that ignored the query string would return data for any request, correct or not. Capturing the parameters makes the hook’s side of the API contract explicit.
Step 3 — Test the error path without waiting for retries. With retry: false the hook reports failure as soon as the handler responds.
test('reports an error when the API fails', async () => {
server.use(http.get('/api/orders', () => new HttpResponse(null, { status: 503 })));
const { wrapper } = queryWrapper();
const { result } = renderHook(() => useOrders({}), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.error?.message).toBe('orders 503');
});
Step 4 — Test that changing parameters fetches again. A new key means a new request; rerendering the hook with new props proves the key includes every input.
test('refetches when the filter changes', async () => {
const statuses: Array<string | null> = [];
server.use(http.get('/api/orders', ({ request }) => {
statuses.push(new URL(request.url).searchParams.get('status'));
return HttpResponse.json({ items: [], total: 0 });
}));
const { wrapper } = queryWrapper();
const { result, rerender } = renderHook(({ status }) => useOrders({ status }), { wrapper, initialProps: { status: 'placed' } });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
rerender({ status: 'shipped' });
await waitFor(() => expect(statuses).toEqual(['placed', 'shipped']));
});
This is the test that catches the most common TanStack Query bug in real codebases: a key that omits one of the inputs. Everything renders, data appears, and the first filter works — but switching filters shows the previous results, because the cache considers them the same query. Recording the requests turns that subtle staleness into an unambiguous count.
Step 5 — Test dependent queries by their sequence. The second query must not fire until the first has the data it needs, and must fire once it does.
test('fetches the customer only after the order is known', async () => {
const calls: string[] = [];
server.use(
http.get('/api/orders/o7', () => { calls.push('order'); return HttpResponse.json({ id: 'o7', customerId: 'c3' }); }),
http.get('/api/customers/c3', () => { calls.push('customer'); return HttpResponse.json({ id: 'c3', name: 'Ada' }); }),
);
const { wrapper } = queryWrapper();
const { result } = renderHook(() => useOrderCustomer('o7'), { wrapper });
await waitFor(() => expect(result.current.data).toEqual({ id: 'c3', name: 'Ada' }));
expect(calls).toEqual(['order', 'customer']);
});
test('makes no request without an order id', () => {
const { wrapper } = queryWrapper();
const { result } = renderHook(() => useOrderCustomer(undefined), { wrapper });
expect(result.current.fetchStatus).toBe('idle');
});
Step 6 — Assert on the cache when caching is the behaviour. When a hook is supposed to populate detail entries from a list, read them back with the key factory.
test('seeds detail entries from the list response', async () => {
server.use(http.get('/api/orders', () => HttpResponse.json({ items: [{ id: 'o1', customerId: 'c1' }], total: 1 })));
const { client, wrapper } = queryWrapper();
const { result } = renderHook(() => useOrdersPrimingDetails({}), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(client.getQueryData(orderKeys.detail('o1'))).toMatchObject({ id: 'o1' });
});
Each of these tests constructs its own wrapper, and that is deliberate even though it looks repetitive. A wrapper shared at file scope reintroduces the shared cache the whole approach exists to avoid; calling the factory inside each test costs microseconds and makes isolation impossible to break by accident.
Verification
Confirm isolation by running the hook tests in shuffled order; with a fresh client per test every seed passes. Then confirm the error tests are fast — each should complete in milliseconds.
npx vitest run src/orders/hooks.test.tsx --sequence.shuffle --reporter=verbose
# ✓ requests the filtered page and returns its items 12ms
# ✓ reports an error when the API fails 6ms
# ✓ refetches when the filter changes 18ms
Finally, prove the key test catches drift: drop filters from the list key. The refetch test must fail with a single recorded request, because the hook now serves the first filter’s cached result for the second.
Troubleshooting
Symptom: “No QueryClient set, use QueryClientProvider”. Diagnosis: renderHook was called without the wrapper. Fix: always pass { wrapper }; a lint rule on renderHook imports in query-hook tests catches omissions.
Symptom: an error test takes several seconds. Diagnosis: the hook sets its own retry option, overriding the client default. Fix: make hook-level retry configurable, or accept a retry override in tests via the client’s setQueryDefaults for that key.
Symptom: the hook never fetches. Diagnosis: enabled is false because a dependency is missing, or initialData satisfies the query. Fix: check fetchStatus in the test; idle with no data means disabled, which may be the intended behaviour to assert.
Symptom: result.current shows stale values after waitFor. Diagnosis: a destructured value was captured before the update. Fix: read from result.current inside assertions, never from a variable assigned earlier.
FAQ
Is renderHook better than rendering a test component?
For hooks whose behaviour is data, yes — it returns the hook’s value directly and avoids inventing markup. For hooks whose behaviour is only meaningful through a component’s rendering, render the component instead.
Should hooks be tested if their components are tested?
Test the hook when it contains logic — parameter building, response shaping, dependencies — and let component tests cover presentation. A hook that only calls useQuery with a key and a fetcher needs little beyond the component tests that already exercise it.
How do I test infinite queries?
Render the useInfiniteQuery hook, wait for the first page, call result.current.fetchNextPage() inside act, and assert on data.pages and the cursor the second request carried. Also assert that hasNextPage becomes false when the server returns no next cursor.
Can the same approach work with SWR?
The principle is identical — a fresh cache per test — but SWR’s mechanics differ; see testing SWR revalidation behaviour.
Related
- Back to Data Fetching & Cache Testing
- Testing optimistic updates and rollback — mutations against the same cache.
- Testing Pinia stores and composables — the fresh-instance principle in Vue.
- Asserting request payloads without brittle snapshots — checking the parameters hooks send.