Data Fetching & Cache Testing

Most components in a modern application show data they did not create. They ask a query library — TanStack Query, SWR, RTK Query, Apollo — for it, render a loading state while it arrives, render the data or an error when it does, and refetch when the data might be stale. Those libraries add a cache between the component and the network, and the cache is where test suites go wrong: data from one test appears in the next, retries turn a quick failure into a slow timeout, background refetches fire after the test has finished, and optimistic updates are tested only on their happy path. This topic belongs to component and integration testing frameworks and covers testing data-driven components with the network intercepted and the cache under control, so every state a user can see is reachable deterministically.

Where the cache sits and where tests control it A component asks a query hook for data, the hook consults a cache, and the cache fetches from the network; tests supply a fresh cache per test with retries off and intercept the network with MSW, leaving the component and hook unchanged. component — renders loading, data, error query hook — useQuery, useSWR cache — fresh per test, retries off network — intercepted by MSW test controls unchanged unchanged
Control the bottom two layers and leave the top two exactly as production runs them.

Architectural Scope & Boundaries

This topic covers the component tier: a component that loads data through a query library, rendered in a simulated DOM with a real query client and an intercepted network. That combination tests the component’s loading, success and error rendering, its interaction with the cache — refetching, invalidation, optimistic updates — and the request it actually sends, all without a server.

The key boundary is what to replace. Mocking the query hook itself — vi.mock('@tanstack/react-query') returning fixed data — is tempting and nearly always wrong. It removes the cache, so staleness, deduplication and invalidation go untested; it removes the network, so the request’s shape goes unchecked; and it couples tests to how the component calls the hook. Intercepting the network instead keeps all of that real while making responses fully controllable.

The second boundary is the query client’s configuration. Production defaults — three retries with backoff, data cached for five minutes, refetch on window focus — are sensible for users and hostile to tests: a failing request takes seconds to surface, and data leaks between tests through the shared cache. Tests need a client configured for them, created fresh for each test, which is the single most important setup decision in this topic.

What this topic does not cover is server-side data fetching in React Server Components or framework loaders, which run outside the client cache; those are covered in React state and hydration testing. Nor does it cover the correctness of the API itself, which belongs to the service’s own tests and to contract testing.

There is also a boundary around hooks tested on their own. A custom hook that wraps a query — useOrders(filters) — can be tested with renderHook and the same fresh client, which is often clearer than rendering a whole component. The guides below use both, choosing by whether the behaviour under test is about the data or about its presentation.

It is worth naming why these libraries became standard, because their benefits are exactly what makes them tricky to test. They deduplicate identical requests, share results between components, keep stale data on screen while fetching fresh data, retry transient failures, and refetch when the user returns to the tab. Every one of those behaviours depends on state held outside any component and on time passing. A test that removes the library removes the behaviours; a test that keeps it must control its state and its clock. That is the whole shape of the techniques below.

Prerequisites

Step-by-Step Implementation

Step 1 — Create a fresh client per test, configured for tests. Retries off so failures surface immediately; garbage collection immediate so nothing lingers; no refetch on window focus.

// test/render-with-query.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import type { ReactElement } from 'react';

export function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: Infinity, staleTime: 0, refetchOnWindowFocus: false },
      mutations: { retry: false },
    },
  });
}

export function renderWithQuery(ui: ReactElement, client = createTestQueryClient()) {
  return { client, ...render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>) };
}

gcTime: Infinity looks surprising in a setup meant to prevent leaks, but the reasoning is sound. The client is thrown away after each test, so nothing it caches can outlive the test; what matters is that it does not schedule garbage-collection timers that keep the worker busy or fire into later tests. Retries are the setting with the biggest practical effect: with the production default, every error-state test waits through several seconds of backoff before the error renders.

Step 2 — Intercept the network, not the hook. Handlers return realistic payloads; each test overrides them for its scenario.

// test/msw/orders.ts
import { http, HttpResponse, delay } from 'msw';

export const orders = {
  list: (items = [{ id: 'o1', total: 4999, status: 'placed' }]) =>
    http.get('/api/orders', () => HttpResponse.json({ items, nextCursor: null })),
  slow: () => http.get('/api/orders', async () => { await delay(200); return HttpResponse.json({ items: [], nextCursor: null }); }),
  failing: () => http.get('/api/orders', () => new HttpResponse(null, { status: 500 })),
};

Keeping handlers in named factories per endpoint gives tests a vocabulary — orders.slow(), orders.failing() — that reads like the scenario being set up. It also keeps realistic default payloads in one place, so when the API adds a field the fixture is updated once and every test benefits, rather than each test carrying its own hand-written and slowly diverging response body.

Step 3 — Test each visible state. Loading, data, empty and error are four different renders, and each deserves an assertion.

test('shows a loading state, then the orders', async () => {
  server.use(orders.slow(), orders.list([{ id: 'o1', total: 4999, status: 'placed' }]));
  renderWithQuery(<OrdersTable />);
  expect(screen.getByRole('progressbar', { name: 'Loading orders' })).toBeInTheDocument();
  expect(await screen.findByRole('row', { name: /o1/ })).toBeInTheDocument();
});

test('shows an empty state when there are no orders', async () => {
  server.use(orders.list([]));
  renderWithQuery(<OrdersTable />);
  expect(await screen.findByText('You have no orders yet')).toBeInTheDocument();
});

test('shows a retryable error when loading fails', async () => {
  server.use(orders.failing());
  renderWithQuery(<OrdersTable />);
  expect(await screen.findByRole('alert')).toHaveTextContent('We could not load your orders');
  expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
});

The empty state deserves emphasis because it is the one teams most often ship broken. It is not a special case of the data state: a table with no rows looks like a failure to users, and a good component explains what an empty result means and what to do next. The error state likewise needs a way forward — a retry control — and a test that clicks it and sees the data load closes the loop on recovery.

Step 4 — Test invalidation after a mutation. When the user changes data, dependent queries should refetch; asserting the refetched content proves the invalidation is wired.

test('cancelling an order refreshes the list', async () => {
  let status = 'placed';
  server.use(
    http.get('/api/orders', () => HttpResponse.json({ items: [{ id: 'o1', total: 4999, status }], nextCursor: null })),
    http.post('/api/orders/o1/cancel', () => { status = 'cancelled'; return HttpResponse.json({ ok: true }); }),
  );
  const user = userEvent.setup();
  renderWithQuery(<OrdersTable />);
  await user.click(await screen.findByRole('button', { name: 'Cancel order o1' }));
  expect(await screen.findByRole('row', { name: /o1.*cancelled/i })).toBeInTheDocument();
});
Mocking the hook versus intercepting the network Mocking the query hook returns fixed data and removes the cache, request and invalidation from the test, while intercepting the network keeps the real hook and cache and still controls every response. vi.mock the hook no cache, no staleness no request to check invalidation untested coupled to the hook's call shape intercept with MSW real hook, real cache request inspectable invalidation proven library swap keeps tests
Intercepting at the network costs the same effort and keeps every layer above it under test.

The handler here mutates a variable the list handler reads, which is a small in-memory stand-in for the server’s state. That is enough to prove the essential wiring: the mutation succeeded, the related query was invalidated, the refetch happened, and the new data rendered. Without invalidation the row would still say “placed”, and the test would fail for exactly the reason users would report.

Step 5 — Seed the cache when the test is about what happens next. A component that renders cached data instantly, then revalidates, is tested by seeding the client before rendering.

test('renders cached orders immediately while revalidating', async () => {
  const client = createTestQueryClient();
  client.setQueryData(['orders'], { items: [{ id: 'cached', total: 100, status: 'placed' }], nextCursor: null });
  server.use(orders.list([{ id: 'fresh', total: 200, status: 'placed' }]));
  renderWithQuery(<OrdersTable />, client);

  expect(screen.getByRole('row', { name: /cached/ })).toBeInTheDocument();
  expect(await screen.findByRole('row', { name: /fresh/ })).toBeInTheDocument();
});

Seeding is also the fastest way to test a component’s behaviour with data it would normally have fetched much earlier — a detail page opened from a list, say, which should render instantly from the list’s cached entry. Setting the query data directly states that precondition in one line, instead of rendering the list, clicking through and waiting, which would test the navigation rather than the detail page’s use of the cache.

Step 6 — Test hooks directly when the behaviour is about data. A hook that combines queries or derives values is clearer to test with renderHook than through a table.

test('useOrderTotals sums only placed orders', async () => {
  server.use(orders.list([{ id: 'a', total: 100, status: 'placed' }, { id: 'b', total: 50, status: 'cancelled' }]));
  const client = createTestQueryClient();
  const { result } = renderHook(() => useOrderTotals(), {
    wrapper: ({ children }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>,
  });
  await waitFor(() => expect(result.current.data).toEqual({ count: 1, totalPence: 100 }));
});

A rule of thumb for choosing between the two: if the assertion is about a number, a list or a derived value, test the hook; if it is about what the user sees or can click, render the component. Hook tests are shorter and fail with clearer messages for data problems, while component tests are the only place loading indicators, empty states and retry buttons can be verified.

Configuration Reference Table

Option Production default Test setting Why
retry 3 with backoff false Errors surface immediately instead of after seconds.
gcTime 5 minutes Infinity Avoids timers firing after the test; the client is discarded anyway.
staleTime 0 0, or per test Keep default unless the test is about staleness.
refetchOnWindowFocus true false jsdom focus events should not trigger surprise fetches.
refetchInterval off fake timers Advance time rather than waiting for polling.
client instance one per app one per test The cache is shared state; a fresh client is isolation.
onUnhandledRequest 'error' Any unexpected fetch fails the test loudly.
dedupingInterval (SWR) 2 seconds 0 Stops SWR serving stale results between tests.

Verification & Assertions

The first verification is isolation. Run the data-fetching tests in shuffled order; with a fresh client per test they all pass, and any that fail are sharing a client or a module-level cache.

npx vitest run src/orders --sequence.shuffle --sequence.seed=5
# ✓ 14 passed

Isolation also covers the network handlers: server.resetHandlers() after each test must restore the defaults, or an error override from one test turns the next test’s data state into an error state.

The second is that errors surface quickly. A suite with retries left on shows error tests taking several seconds each; with retries off they take milliseconds. Sorting tests by duration finds any that still retry.

The third, and most valuable, is asserting on the request as well as the rendering for any query with parameters. Filters, pagination cursors and search terms are where bugs hide — a filter applied in the interface but not sent — and a handler that captures the request’s search parameters verifies them directly, as covered in asserting request payloads without brittle snapshots.

A fourth check worth adding for queries that paginate or poll is a count of requests. A handler that increments a counter lets a test assert that scrolling to the next page makes exactly one request, or that polling stops when the component unmounts. Over-fetching is invisible in the rendered output and expensive in production, and a request count is the only thing that reveals it.

Edge Cases & Failure Modes

Cache leakage through a module-level client. An application that creates its QueryClient at module scope and imports it into a provider shares that cache across every test in a file. Diagnose by order-dependent failures showing data from a previous test; fix by constructing the client inside the provider and passing a fresh one in tests.

Mutations whose errors are swallowed. A mutation that fails without an onError handler leaves the interface in its optimistic or pending state with no message. Diagnose with a failing mutation handler and an assertion that an error appears; fix by handling errors explicitly, and keep that failing-mutation test permanently.

Background refetches after the test ends. Stale data triggers a refetch on mount; if a test finishes before it resolves, the response arrives into an unmounted tree and produces warnings, or into the next test. Await the settled state in every test, and let cleanup unmount before the next begins.

Structural sharing hiding updates. TanStack Query preserves object identity when refetched data is deeply equal, so a component memoised on identity will not re-render. That is correct behaviour; tests that expect a re-render after an identical refetch are testing the wrong thing.

Suspense mode changing the shape of tests. With useSuspenseQuery, the loading state is a Suspense fallback rather than a flag, and errors propagate to an error boundary. Render the component inside the boundaries production uses, or the test sees an uncaught promise instead of the fallback.

The states a data-driven component must render Loading, data, empty, error and refreshing-with-stale-data are five distinct states, each produced deterministically by one MSW handler or a seeded cache, and each deserving its own assertion. loading delay() data json(items) empty json([]) error status 500 refreshing seeded cache one handler or one seed per state — none needs a real server
The empty and refreshing states are the ones most often forgotten, and each is one line to produce.

Query keys that do not include every input. A key of ['orders'] for a query that also depends on a filter means changing the filter serves cached results for the old one. Diagnose by a test that changes a filter and sees stale rows; fix by including every input in the key, and assert on the refetched request’s parameters.

Performance & CI Impact

With retries off and the network intercepted, data-fetching component tests are as fast as any other component test — typically a few milliseconds each beyond render time. The single biggest slowdown in suites that have not adopted these settings is retry backoff on error tests, which can add three to seven seconds per failing query; turning retries off in the test client removes it entirely.

Creating a new client per test costs microseconds and is never a meaningful expense. What does cost time is gcTime timers when left at their default: each cached query schedules a removal timer, and with thousands of tests those timers keep workers alive after tests complete. Setting gcTime to Infinity in tests avoids scheduling them at all, since the whole client is discarded.

Flakiness in this area almost always comes from asserting too early — before data arrives, before an invalidation refetch resolves, before a mutation settles. findBy queries and waitFor absorb that without real waits, and MSW’s explicit delay makes timing-dependent states such as loading reproducible rather than dependent on how fast the machine happens to be.

Finally, keep the render helper and handler factories in the shared test utilities rather than per feature. Every data-driven component in the application needs the same client configuration, and a helper duplicated across features will eventually diverge — one copy with retries on, another with a shared client — producing a suite whose speed and reliability vary by directory for no reason anyone remembers.

In-Depth Guides