Avoiding act() Warnings in React Testing Library
The “An update to Component inside a test was not wrapped in act(…)” warning is the single most common — and most misunderstood — failure in a React Testing Library suite. It rarely means your test is wrong about what it asserts; it means a state update happened after the test thought the component had settled. This guide is for frontend developers and QA engineers running React 18/19 component tests under Vitest (the patterns are identical under Jest) who want to eliminate the warning at its source rather than silence it. We cover exactly what triggers it, how findBy*, waitFor, and awaited userEvent resolve it, and the specific extra step fake timers require. It sits under Testing Library best practices as the deep dive on async synchronization.
Root Cause Analysis
act() is React’s boundary for “apply all the effects and state updates this interaction caused, then let the DOM settle.” Testing Library already wraps render and its event helpers in act() for you, so you almost never call it directly. The warning fires when a state update lands outside any act() scope — which happens whenever an update is scheduled asynchronously and the test function returns (or moves to the next assertion) before that update is flushed. The DOM mutates after React believes the test is done, and React has no act() scope to attribute the change to, so it logs the warning.
Concretely, four patterns trigger it. First, a fetch/promise resolves after the test body finishes, updating state on a now-“idle” tree. Second, a setTimeout or debounced callback fires later and calls setState. Third, a userEvent interaction is invoked without await, so its internal act() resolves after the next assertion runs. Fourth — the subtlest — fake timers freeze the clock, so the promises and timers that userEvent and waitFor rely on never advance unless you explicitly tie them together. In every case the cure is the same in spirit: make the test wait inside an act-aware utility until the asynchronous update has actually been applied. Routing the component’s data through simulated handlers with MSW v2 is what makes that wait deterministic instead of a race.
Reproducible Setup
Start from a jsdom-backed Vitest project with the Testing Library toolchain and a component that updates state after an async call — the canonical act-warning generator.
npm install -D vitest jsdom @testing-library/react \
@testing-library/jest-dom @testing-library/user-event @vitejs/plugin-react msw
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
});
// src/components/Profile.tsx — updates state after an async fetch
import { useEffect, useState } from 'react';
export function Profile() {
const [name, setName] = useState<string | null>(null);
useEffect(() => {
fetch('/api/me')
.then((r) => r.json())
.then((data) => setName(data.name)); // late setState → act warning if unsynchronized
}, []);
return <h1>{name ?? 'Loading…'}</h1>;
}
A naive test asserts immediately and triggers the warning:
// ❌ Triggers: "not wrapped in act(...)"
import { render, screen } from '@testing-library/react';
import { Profile } from './Profile';
test('shows the name', () => {
render(<Profile />);
expect(screen.getByText('Ada')).toBeInTheDocument(); // fetch resolves AFTER this line
});
Implementation
Apply these fixes in order; each addresses one of the four trigger patterns.
1. Use findBy* for elements that appear after an async update.
A findBy* query is a getBy* wrapped in waitFor; it retries inside an act() scope until the element exists, flushing the pending state update. Pair it with simulated handlers so resolution is deterministic.
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { Profile } from './Profile';
const server = setupServer(
http.get('/api/me', () => HttpResponse.json({ name: 'Ada' })),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('shows the name once the request resolves', async () => {
render(<Profile />);
expect(await screen.findByText('Ada')).toBeInTheDocument(); // waits inside act()
});
2. Use waitFor for non-DOM consequences.
When the awaited result is not “an element appeared” — for example a mock was called, or text was removed — wrap the assertion in waitFor (or use waitForElementToBeRemoved). It re-runs the callback inside act() until it stops throwing.
import { waitFor, waitForElementToBeRemoved, screen } from '@testing-library/react';
await waitForElementToBeRemoved(() => screen.queryByText('Loading…'));
await waitFor(() => expect(onLoaded).toHaveBeenCalledTimes(1));
3. Always await userEvent.
userEvent.setup() returns an API whose methods are async and internally act-wrapped. Forgetting the await lets the assertion run before the interaction’s state update flushes.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('reveals details on click', async () => {
const user = userEvent.setup();
render(<Accordion />);
await user.click(screen.getByRole('button', { name: /details/i })); // await is mandatory
expect(screen.getByText(/full description/i)).toBeVisible();
});
4. Wire fake timers into userEvent.
Fake timers freeze setTimeout and microtask draining, so userEvent’s internal delays and waitFor’s polling stall forever unless you hand userEvent an advancer. Pass advanceTimers at setup, and let waitFor advance the clock itself.
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
test('shows debounced results with fake timers', async () => {
vi.useFakeTimers();
// Tie userEvent's internal delays to the fake clock
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(<Search />);
await user.type(screen.getByRole('searchbox'), 'react');
// Drive the debounce timer forward inside an act-aware utility
await waitFor(() => {
vi.advanceTimersByTime(300);
expect(screen.getByText(/results for "react"/i)).toBeInTheDocument();
});
vi.useRealTimers();
});
The full mechanics of freezing and advancing the clock — including Date.now control — live in controlling Date.now and setTimeout and the broader time and date control strategies guide.
Verification
With the fixes applied, a full run is silent on act warnings. The most reliable verification is to turn the warning into a hard failure so it can never slip back in unnoticed.
// vitest.setup.ts
import '@testing-library/jest-dom';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => cleanup());
const error = console.error;
console.error = (...args: unknown[]) => {
error(...args);
// Any act warning now fails the test instead of scrolling past in logs
if (String(args[0]).includes('not wrapped in act')) {
throw new Error(`act() warning: ${args.join(' ')}`);
}
};
A clean run then looks like this — no warning lines between the pass markers:
npx vitest run
# ✓ src/components/Profile.test.tsx (1 test) 52ms
# ✓ src/components/Search.test.tsx (1 test) 67ms
#
# Test Files 2 passed (2)
# Tests 2 passed (2)
Troubleshooting
The warning persists even with findBy*. Symptom: a second, unrelated state update fires after the awaited element appears. Diagnosis: the component schedules a follow-up update (a second fetch, a timer) that you are not waiting for. Fix: extend the wait to the final observable state with an additional waitFor, or assert on the element that only renders after the last update completes.
waitFor times out with fake timers. Symptom: the test hangs until the asyncUtilTimeout and fails. Diagnosis: the fake clock is frozen and nothing advances it, so the polled callback never re-evaluates. Fix: either advance the clock inside the waitFor callback (as in Implementation step 4) or pass advanceTimers to userEvent.setup() so timers progress as the interaction runs.
Warning only appears in CI, never locally. Symptom: green locally, red on the runner. Diagnosis: a real network call is succeeding locally but timing out or returning different data in CI, so the state update lands at a different moment. Fix: simulate the endpoint with MSW and set onUnhandledRequest: 'error' so any un-mocked call fails loudly rather than racing — the pattern is detailed in external service simulation.
FAQ
Should I ever import and call act() manually?
Almost never. Testing Library wraps render, findBy*, waitFor, and userEvent in act() for you, so a manual act() is a sign you are missing one of those utilities. The legitimate exceptions are testing a custom hook outside a component or directly invoking a callback that triggers state — and even then, renderHook from @testing-library/react usually removes the need.
Why does awaiting userEvent matter if the click looks synchronous?
Because userEvent.setup() returns asynchronous methods that simulate the full pointer-and-focus sequence and wrap the resulting React updates in act(). Without await, your assertion runs before that wrapped update resolves, so the DOM you assert against is stale and React logs the warning when the update finally lands. Awaiting every interaction is the rule, not an optimization.
Does this work with Jest as well as Vitest?
Yes — the act mechanism belongs to React, not the runner, so findBy*, waitFor, and awaited userEvent behave identically. The only differences are the fake-timer API (jest.useFakeTimers() and jest.advanceTimersByTime in place of the vi equivalents) and the setup-file key. Vitest is shown here as the primary runner for its faster cold start.
Can I just raise the test timeout to make the warning go away?
No. The timeout controls how long findBy*/waitFor retry; it does nothing about an update that fires outside any wait. Raising it can mask a slow async path but leaves the unsynchronized update — and its warning — intact. Synchronize the update with the right utility instead, and keep the timeout tight so genuine hangs surface quickly.
Related
- Back to Testing Library Best Practices
- Migrating from Enzyme to React Testing Library in 2024 — where these async patterns replace Enzyme’s
simulate. - Controlling Date.now and setTimeout in Jest — the fake-timer mechanics this guide depends on.
- React State & Hydration Testing — synchronize state in server-rendered and hydrated trees.