Clearing localStorage and globals between component tests

This page is part of the isolation principles section of the component testing fundamentals guide, which covers cutting a component free from everything outside it before you assert on it.

The specific problem: a component writes a dismissal flag to localStorage. The test that dismisses it passes. The test after it — which expects the banner visible — fails, because the flag is still there.

Problem statement

localStorage, sessionStorage, document.cookie, and anything hung off globalThis live on the JSDOM instance, not on the component. Testing Library’s cleanup unmounts the React tree; it does not empty browser storage, because storage is not part of the tree.

The symptom is order dependence: the file passes when run alone and fails in a full run, and moving one test above another changes the outcome. Under a thread pool the same JSDOM window can be shared across files, so the failure can even appear in a file that never touches storage.

Root cause

Each of these surfaces is a mutable global that outlives the component that wrote to it. A component writing a dismissal flag is doing exactly what it does in production; the difference is that in production the next page load is a new browsing session, and in a test the next case is not.

The isolation boundary that handles module state — a fresh registry per file — does nothing here, because storage is not a module. It needs its own teardown, and that teardown has to run between tests, not only between files.

What cleanup() does and does not clear Five bands ordered by lifetime. Component state disappears at unmount with no action needed. DOM nodes are cleared by cleanup in afterEach. Local and session storage, highlighted, survive both cleanup and the whole file. Cookies behave the same way. Anything hung off globalThis survives everything until the process ends. What survives what Component state (useState) gone at unmount — nothing to do DOM nodes cleared by cleanup() in afterEach localStorage / sessionStorage survives cleanup AND the whole file document.cookie survives cleanup AND the whole file globalThis.__flags survives everything until the process ends Only the first two are handled for you; the rest need explicit teardown

Minimal reproduction

// Banner.tsx
export function Banner() {
  const [hidden, setHidden] = useState(() => localStorage.getItem('banner-dismissed') === '1');
  if (hidden) return null;
  return (
    <div role="status">
      Welcome back
      <button onClick={() => { localStorage.setItem('banner-dismissed', '1'); setHidden(true); }}>
        Dismiss
      </button>
    </div>
  );
}
// the second test depends on the first not having run
it('dismisses the banner', async () => {
  render(<Banner />);
  await userEvent.click(screen.getByRole('button', { name: /dismiss/i }));
  expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

it('shows the banner for a new visitor', () => {
  render(<Banner />);
  expect(screen.getByRole('status')).toBeInTheDocument();   // fails: flag still set
});

Step-by-step fix

1. Clear every storage surface in one teardown hook

// src/setupTests.ts
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';

afterEach(() => {
  cleanup();                    // unmount the React tree
  localStorage.clear();         // and empty the surfaces cleanup does not touch
  sessionStorage.clear();
  document.cookie.split(';').forEach((c) => {
    const name = c.split('=')[0].trim();
    document.cookie = name + '=;expires=' + new Date(0).toUTCString() + ';path=/';
  });
});

What this does: puts the teardown in the one file every project config already points at, so no test file can forget it.

2. Reset the document itself

Portals, focus, and body classes survive cleanup as well, because they live outside the container it unmounts.

afterEach(() => {
  document.body.className = '';
  document.body.innerHTML = '';     // removes portal roots left behind
  document.title = '';
});

What this does: returns the document to a known shape, so a modal that appended a portal root cannot affect the next test’s queries.

3. Restore globals you deliberately stubbed

// stubbed in a test
vi.stubGlobal('matchMedia', (q: string) => ({
  matches: q.includes('dark'),
  media: q,
  addEventListener: vi.fn(),
  removeEventListener: vi.fn(),
}));
// vitest.config.ts — reverse every stub automatically
export default defineConfig({
  test: { unstubGlobals: true, restoreMocks: true },
});

What this does: unstubGlobals reverses every stubGlobal after each test, so a stubbed matchMedia cannot leak a dark-mode preference into an unrelated test.

4. Seed state explicitly rather than relying on emptiness

A test that needs a flag set should set it, not depend on a previous test having done so.

it('hides the banner for a returning visitor', () => {
  localStorage.setItem('banner-dismissed', '1');   // this test's own precondition
  render(<Banner />);
  expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

What this does: makes the test readable in isolation and independent of run order in both directions.

Verification

Shuffled runs proving order independence Three Vitest runs with shuffled ordering under different seeds. Each runs three files and fourteen tests and passes. The suite no longer depends on the order tests happen to execute in. vitest run --sequence.shuffle seed 1739482 PASS 3 files, 14 tests seed 8823014 PASS 3 files, 14 tests seed 4410927 PASS 3 files, 14 tests no order dependence detected

Shuffling is the verification that matters. A suite that passes in file order but fails on some seeds still has a leak; a suite that passes across several seeds has genuinely removed the dependence.

# run it a few times — any seed that fails names a surface still leaking
vitest run --sequence.shuffle --sequence.seed=1739482

Edge cases and caveats

Six surfaces and the call that clears each Six rows. The React tree is cleared by cleanup and does not survive the file. Local storage, session storage and cookies are untouched by cleanup and survive the whole file; they are cleared with clear calls and cookie expiry respectively. Body classes and leftover portal roots also survive and need the document body reset. Stubbed globals survive until unstubGlobals is enabled in the runner config. Surface Cleared by cleanup() Survives the file Clear it with React tree yes no nothing extra localStorage no yes localStorage.clear() sessionStorage no yes sessionStorage.clear() document.cookie no yes expire each cookie body class / portals no yes reset document.body stubbed globals no yes unstubGlobals: true
  • happy-dom versus jsdom. Both implement storage, but happy-dom recreates its window per file more aggressively, which can hide a leak locally that appears under jsdom in CI. Whichever you use, clear explicitly rather than relying on the environment’s recycling behaviour.

  • IndexedDB and the Cache API. Neither is implemented by default. A component using them needs a fake — fake-indexeddb for the former — and that fake needs its own reset in the same hook, or it becomes another surface that persists.

  • Storage events across tabs. A component listening for the storage event will never receive one in a single JSDOM window, since the event only fires in other documents. Dispatch it manually if the listener is what you are testing.

FAQ

Why does cleanup() not clear localStorage?

Because they are unrelated concerns. cleanup is Testing Library’s function for unmounting what render mounted — it walks the containers it created and removes them. Browser storage belongs to the JSDOM window, which Testing Library neither created nor owns. Clearing it is the test environment’s job, which in practice means your setup file.

Should I mock localStorage instead of clearing it?

Clearing is simpler and tests more of the real path. A mock lets you assert on calls — useful when the assertion genuinely is “the component persisted the flag” — but it also means the component is no longer exercising the real storage API, so a quota error or a serialisation bug goes unnoticed. Use the real implementation and clear it; add a spy on top only when you need to assert the call itself.

Does pool: 'forks' remove the need for this?

Only between files. Fork pooling gives each file a fresh process and therefore a fresh JSDOM window, so storage cannot cross files — but every test inside one file still shares that window. The failure in the reproduction above is between two tests in the same file, and process isolation does nothing for it.

How do I find which surface is leaking?

Snapshot the suspects in beforeEach and assert they are unchanged in afterEach. A hook that records localStorage.length, document.cookie, and document.body.innerHTML.length before each test and compares afterwards will fail in the exact test that dirtied one of them, which turns an order-dependent mystery into a named failure at its source.