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.
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
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
-
happy-domversusjsdom. Both implement storage, buthappy-domrecreates its window per file more aggressively, which can hide a leak locally that appears underjsdomin 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-indexeddbfor 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
storageevent 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.
Related
- Isolation Principles — the parent section on cutting a component free from its environment
- Preventing React Context Leaks Across Parallel Vitest Workers — the module-registry half of the same problem
- State Injection — supplying known state instead of inheriting whatever is left over
- Async Behaviour and Timers — the other common source of order-dependent failures