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.

It helps to picture the timeline. React 18’s automatic batching folds multiple setState calls that originate from the same tracked scope into a single render, but batching only helps when those calls happen inside that scope. An update that arrives from a resolved promise, a timer callback, or a streamed chunk starts a fresh, untracked task, so React cannot merge it into the render it already flushed. The warning is therefore a timing statement, not a correctness one: your assertion observed the tree one microtask too early. Crucially, the message names the component whose state changed, not the line that failed — so read it as a pointer to what updated late, then trace backward to the interaction or effect that scheduled it. Every fix below is a way to move the observation to the moment the update actually lands.

Timeline of a state update landing outside the act() boundary Render and assertions run inside Testing Library's act() scope, but a late fetch resolves after the test returns, so its state update has no act() scope to attribute it to and React logs the warning. When does the update land? act() scope render() wrapped in act() getByText() assertion runs test returns tree marked idle fetch resolves setState fires late no act() scope here not wrapped in act(...) warning
The warning fires because the state update lands after the test returns, outside any act() scope.

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. The decision map below is the quickest way to pick the right utility: classify what the async update produces — a new element, a non-visual effect, a user interaction, or a frozen clock — and the correct tool follows directly.

Decision map from the kind of async update to the right synchronization utility Starting from an async update after render, the diagram branches on what the update produces and points each case to findBy, waitFor, awaited userEvent, or advanceTimers. Async update after render? what does it produce? element appears effect / removal click or type clock frozen findBy* retries inside act() waitFor polls inside act() await userEvent act-wrapped API advanceTimers drive the clock every branch resolves the update inside an act-aware wait
Pick the synchronization utility from what the async update produces.

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. Its default budget is a 1000 ms timeout polled every 50 ms, which you can widen per call for a genuinely slow path — but treat a query that needs seconds as a signal to simulate the dependency rather than to wait longer. 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 reason this dance is necessary is that userEvent and waitFor both await real microtasks internally, while fake timers suspend the very timers those awaits depend on. Handing userEvent an advanceTimers function reconnects the two clocks, and advancing time inside the waitFor callback lets the debounce fire and the poll re-check in the same act-flushed tick. Miss either half and the test deadlocks: the interaction waits on a timer that never ticks. The sequence below shows the three collaborators keeping the fake clock and the act scope in step.

How userEvent, the fake clock, and waitFor coordinate under fake timers userEvent's advanceTimers hook and waitFor's explicit advanceTimersByTime both drive the same fake clock, so the debounce callback flushes inside an act scope and the assertion passes without a warning. userEvent.setup fake clock waitFor advanceTimers wired at setup advanceTimersByTime(300) debounce setState flushes in act() assertion passes — no act() warning
Both userEvent and waitFor drive the same fake clock so the debounced update flushes inside act().

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.

An awaited userEvent still warns on one control. Symptom: every interaction is awaited, yet a specific button keeps logging the warning. Diagnosis: the handler kicks off an un-awaited async chain of its own — an optimistic update immediately followed by a background revalidation — so a second state change lands after the interaction’s own act() resolves. Fix: assert on the settled end state with findBy*, or waitForElementToBeRemoved a transient spinner, so the wait spans both updates rather than only the first.

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.

Is wrapping the whole test body in one waitFor a valid fix?

No — a single waitFor around an entire test retries every line it contains, including irreversible actions like clicks, so an interaction can fire many times and produce a misleading pass. Scope each wait to the narrowest assertion that proves the async update landed, and keep side-effecting calls such as userEvent outside the retried callback. The rule of thumb: a waitFor body should contain assertions only, never actions.

Does disabling console.error in the setup file count as fixing it?

No, and it is the most damaging shortcut of all. Muting console.error hides the act warning together with every genuine React error — key warnings, invalid-prop errors, and thrown exceptions in effects — so the suite goes quiet while real defects ship. The Verification pattern does the opposite: it promotes the warning to a thrown error so it can never be ignored. Fix the synchronization, never the logger.