Managing Component State During Automated Tests

React context leaks across parallel Vitest workers and leaves your suite failing on CI while passing locally. This page is a focused fix for that exact symptom — part of the broader state injection patterns that keep component tests deterministic.

Problem Statement

Your CI run shows 5–15% of component tests failing with mismatched snapshots, unexpected re-renders, or stale context values — yet every test passes when run in isolation with --testNamePattern. The failures are non-deterministic: different tests fail on each push.

Root Cause

When Vitest (or Jest) runs tests in the same worker process, module-level singletons — Zustand stores, React Query QueryClient instances, global window listeners — retain mutations from one test file into the next. The cleanup() call from @testing-library/react unmounts the React tree, but it does not reinitialise JavaScript heap state. Parallel workers sharing memory (the default --pool=threads mode) make the window of contamination unpredictable. These failures are a direct consequence of bypassing explicit state injection at the render boundary and relying on ambient global state instead.

The diagram below shows how state flows through a typical test run and where leakage occurs without explicit reset boundaries.

State lifecycle showing where leakage occurs across tests in the same worker A timeline of a Vitest worker run. Module initialisation creates a singleton store. Test A mutates it. cleanup() fires but only unmounts the React tree. The store survives. Test B starts with polluted state, causing a false failure. Module init store = {} Test A render() + interact store.count = 5 cleanup() → tree unmounted store.count still = 5 Test B reads store expects count = 0 FAIL ✗ afterEach: resetTestState() → store = {} Without explicit reset, module-level singletons survive cleanup() and pollute subsequent tests

Minimal Reproduction

This is the smallest test file that reliably triggers cross-test state pollution with a Zustand store:

// counter.store.ts  — module-level singleton (the problem)
import { create } from 'zustand';
export const useCounter = create<{ count: number; inc: () => void }>(set => ({
  count: 0,
  inc: () => set(s => ({ count: s.count + 1 })),
}));
// counter.test.tsx  — run this file alone: both pass; run full suite: Test B fails
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';

test('Test A — increments count', () => {
  render(<Counter />);
  fireEvent.click(screen.getByRole('button', { name: /increment/i }));
  // store.count is now 1 — cleanup() fires but Zustand module-singleton keeps it
});

test('Test B — shows initial count of 0', () => {
  render(<Counter />);
  // FAILS: store.count is still 1 from Test A because the module was never reinitialised
  expect(screen.getByText('0')).toBeInTheDocument();
});

Step-by-Step Fix

1. Add an explicit state reset hook

Create a shared utility that clears every layer of ambient state before each test.

// test-utils/reset-state.ts
import { cleanup } from '@testing-library/react';
import { vi } from 'vitest';
import { server } from './mocks/server';
import { useCounter } from '../counter.store';

export const resetTestState = () => {
  // Restore real timers and discard pending fake ones
  vi.useRealTimers();
  vi.clearAllTimers();
  vi.clearAllMocks();

  // Reset MSW network handlers to their baseline set
  server.resetHandlers();

  // Reinitialise Zustand store to its initial state
  useCounter.setState({ count: 0 });

  // Unmount React trees and purge the jsdom document
  cleanup();
};

Wire it into every file via the Vitest setupFiles so no test file needs to call it manually:

// vitest.setup.ts
import { resetTestState } from './test-utils/reset-state';

beforeEach(resetTestState);
afterEach(resetTestState); // belt-and-suspenders for async leaks
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    pool: 'forks',          // isolate heap per file; threads share memory
    setupFiles: ['./vitest.setup.ts'],
    testTimeout: 10_000,
  },
});

2. Replace inline provider trees with a factory that creates fresh instances

Shared QueryClient objects cache data between tests unless you create a new instance per render. The same applies to any context with internal state.

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

export function renderWithProviders(ui: ReactNode) {
  // New QueryClient per call — zero cache, zero retry noise
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false, gcTime: 0, staleTime: 0 },
      mutations: { retry: false },
    },
  });

  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

3. Freeze the network layer with strict MSW boundaries

Open network requests that resolve after the test ends silently mutate state in later tests. Register a mock boundary that controls every route your component can call:

// mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/config', () =>
    HttpResponse.json({ featureFlags: { betaUI: false } })
  ),
  http.post('/api/submit', () =>
    new HttpResponse(null, { status: 202 })
  ),
];
// mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// vitest.setup.ts  (extend the setup file from step 1)
import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());

Setting onUnhandledRequest: 'error' turns any unmocked network call into an explicit test failure rather than a silent pass-through that could return real or cached data.

4. Flush the async microtask queue before asserting

Unresolved Promise chains attached to one test’s render can resolve during the next test’s execution. Drain them explicitly:

// test-utils/async-helpers.ts

/** Flush all pending resolved Promises and queued microtasks. */
export const flushMicrotasks = () =>
  new Promise<void>(resolve => setTimeout(resolve, 0));

/** Flush + advance fake timers in one call when vi.useFakeTimers() is active. */
export const drainTimers = async () => {
  vi.runAllTimers();
  await flushMicrotasks();
};

Call await flushMicrotasks() at the end of any test that renders async components before the suite teardown fires.

Verification

Run the previously failing test file in both isolation and full-suite mode:

# Isolated — should always pass
npx vitest run --reporter=verbose --testNamePattern="counter"

# Full suite with forks — confirm zero failures across 3 consecutive runs
NODE_OPTIONS="--max-old-space-size=4096" npx vitest run --pool=forks --reporter=verbose

Expected terminal output after applying all four steps:

 PASS  src/Counter.test.tsx (312ms)
   ✓ Test A — increments count (48ms)
   ✓ Test B — shows initial count of 0 (31ms)

Test Files  1 passed (1)
Tests       2 passed (2)

If Test B still fails, add a debug log to resetTestState to confirm the store reset fires:

export const resetTestState = () => {
  console.log('[resetTestState] store before:', useCounter.getState());
  useCounter.setState({ count: 0 });
  // …
};

A log showing count: 1 confirms the reset is running but the store reference is stale — usually caused by a cached module import. In that case, switch to --pool=forks if you have not already, or add vi.isolateModules() around the import.

Edge Cases / Caveats

  • React 18 concurrent mode: act() wrapping is mandatory for state updates that trigger concurrent transitions. Missing act() causes warnings that look like state leaks but are actually async batching issues. Wrap user interactions with await act(async () => { … }) when using createRoot.
  • Zustand devtools middleware: The Redux DevTools extension patches the store’s setState on import. In a --pool=threads run, two workers can race on the same patched reference. Either switch to --pool=forks or strip devtools middleware in the test environment: create(devtools(…))create(process.env.NODE_ENV === 'test' ? fn : devtools(fn)).
  • localStorage and sessionStorage: jsdom does not clear localStorage between tests by default. Add window.localStorage.clear() and window.sessionStorage.clear() to your resetTestState utility if any component reads from storage on mount.

FAQ

Why does vi.clearAllMocks() not reset a Zustand store?

vi.clearAllMocks() resets spy call history and mock implementations, but Zustand stores are plain JavaScript objects — not Vitest mock functions. You must call the store’s own setState with its initial value, or use zustand/testing’s createWithEqualityFn reset helper if you are on Zustand v4+.

Does cleanup() from Testing Library cover all teardown?

cleanup() unmounts React trees and removes rendered nodes from the document, but it does not clear timers, mock implementations, Zustand stores, or MSW handler overrides. You need explicit vi.clearAllMocks(), vi.useRealTimers(), and server.resetHandlers() alongside it.

When should I use --pool=forks vs --pool=threads?

Use forks when tests rely on global state (window, localStorage, module-level singletons) because each fork gets its own process heap. Threads are faster but share memory — safe only when every test uses isolated module instances via vi.isolateModules() or the store is explicitly reset between tests.