Fixing act() warnings in React Testing Library
This page is part of the async behaviour and timers section of the component testing fundamentals guide, which covers making timing-dependent assertions deterministic.
The specific problem: your test passes, but the console fills with An update to X inside a test was not wrapped in act(...). The warning is noise until the day a colleague adds --silent=false to CI and the build starts failing on it.
Problem statement
React Testing Library already wraps render and fireEvent in act, so the warning almost never means what its text suggests. It means React committed a state update that no code the test was awaiting had accounted for — the update happened after the awaited expression returned.
The symptom is a passing test with a warning, or an intermittently failing test with the same warning, and the stack trace points at a component rather than at the line of test code responsible.
Root cause
act is React’s marker for “I am about to cause updates; flush them before continuing”. When an update originates from something the test never awaited — a resolved promise from a request, a setTimeout callback, an IntersectionObserver firing — React has no enclosing act scope to attribute it to, so it warns.
Every instance therefore traces back to a piece of asynchronous work the test did not wait for. That work is usually a request that should have been intercepted at the mock boundary, or a timer that should have been under the control of a fake clock. Wrapping the assertion in act by hand silences the message and leaves the race in place.
Minimal reproduction
// UserBadge.tsx — fetches on mount
export function UserBadge({ id }: { id: string }) {
const [name, setName] = useState<string | null>(null);
useEffect(() => {
fetch(`/api/users/${id}`).then((r) => r.json()).then((u) => setName(u.name));
}, [id]);
return <span>{name ?? 'Loading…'}</span>;
}
// ❌ asserts the loading state, then the fetch resolves after the test ends
it('renders the badge', () => {
render(<UserBadge id="42" />);
expect(screen.getByText('Loading…')).toBeInTheDocument();
});
// Warning: An update to UserBadge inside a test was not wrapped in act(...)
The test passes. It asserts the loading state correctly and then finishes, and the fetch resolves into a component React has already been told to forget about.
Step-by-step fix
1. Intercept the request so its timing is yours
An unmocked fetch resolves whenever the network feels like it. Handling it at the boundary makes the resolution immediate and predictable.
// src/setupTests.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const server = setupServer(
http.get('/api/users/:id', () => HttpResponse.json({ id: '42', name: 'Ada Lovelace' })),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
What this does: the request never leaves the process, so the promise resolves on the next microtask rather than at an unknown future point.
2. Await the state the update produces
Replace the synchronous assertion with one that waits for the post-update DOM. This is what gives React an act scope covering the commit.
it('renders the badge', async () => {
render(<UserBadge id="42" />);
expect(screen.getByText('Loading…')).toBeInTheDocument();
// findByText polls until the fetch has resolved and React has committed
expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
});
What this does: the test now ends after the update rather than before it, so there is no orphaned commit to warn about.
3. Await the removal when there is nothing new to find
Some updates remove content rather than adding it. waitForElementToBeRemoved covers that case without inventing an element to query.
render(<UserBadge id="42" />);
await waitForElementToBeRemoved(() => screen.queryByText('Loading…'));
What this does: polls until the loading text is gone, which is the same commit boundary — expressed from the other side.
4. Control any timer the component owns
If the update comes from a timer rather than a promise, awaiting is not enough — nothing will resolve until the timer fires. Advance it deliberately.
vi.useFakeTimers({ shouldAdvanceTime: true });
render(<AutoDismissToast timeoutMs={4000} />);
await vi.advanceTimersByTimeAsync(4000);
expect(screen.queryByRole('status')).not.toBeInTheDocument();
What this does: the dismissal happens inside an awaited expression, so it is inside an act scope, and the test does not spend four real seconds waiting.
Verification
Run the suite with warnings promoted to failures to prove the fix holds rather than merely being quieter:
# fails the run on any console.error, including React's act warning
vitest run --silent=false --reporter=verbose
The run should complete with an empty stderr. Any remaining warning names the component whose update is still unawaited, which is the next one to fix.
Edge cases and caveats
IntersectionObserverandResizeObservercallbacks. These fire outside React’s scheduling entirely, so an update from one warns even when every promise is awaited. Stub the observer in the setup file and invoke its callback explicitly inside the test, which puts the update back under your control.
-
React 18 concurrent features. An update wrapped in
startTransitionis deferred to a lower-priority lane and may land after the default one-second poll window. Give the awaiting query a longer timeout rather than reaching for a manualact. -
Testing Library already calls
actfor you. If you find yourself writingawait act(async () => {})to flush pending work, that is a sign an intercepted promise or a controlled timer is missing. The manual flush works, but it hides which piece of asynchronous work was unaccounted for.
Finding the unawaited work
When the warning names a component but not a cause, three checks narrow it down quickly.
First, run the single file with warnings promoted to failures. vitest run <file> --silent=false turns the warning into a stack you can read, and the topmost frame inside your own code is usually the effect that scheduled the work.
Second, count what is still scheduled when the test ends. Asserting vi.getTimerCount() is zero in afterEach fails loudly in exactly the tests that leave a timer behind, which converts “somewhere in this file” into “this test”.
Third, check what the render wrapper mounts. A QueryClientProvider with retries enabled will schedule a background refetch that outlives a short test. Setting retry: false and a zero gcTime in the test client removes an entire family of these warnings without touching the component.
The rule that emerges from all three is the same: every asynchronous thing a test starts should be either awaited or cancelled before the test returns. The warning is React telling you one was neither.
FAQ
Can I just turn the warning off?
You can suppress it, and you will regret it. The warning is the only signal that a component is updating outside the window your test controls, which is the same condition that produces intermittent failures later. Suppressing it converts a loud, cheap diagnostic into a silent, expensive one — and the failure that eventually surfaces will appear in a different file from the one that caused it.
Why does the warning name a component I am not testing?
Because a shared provider or a parent rendered by your wrapper is the thing updating. A QueryClientProvider that resolves a background refetch, or a theme provider reacting to a media query, will both warn from their own name while the unawaited work belongs to your test. Look for what your render wrapper mounts, not only at the component under test.
Does the warning exist in React 19?
Yes, and the fix is unchanged. React 19 removed the need for some manual act calls in application code and tightened others, but the underlying rule is the same: an update that lands outside an awaited scope is an update the test did not account for.
Is await act(async () => {}) ever the right answer?
Occasionally, for third-party code that schedules work you genuinely cannot reach — a chart library that measures itself on a requestAnimationFrame, for instance. Treat it as a last resort and leave a comment naming the dependency, because the pattern is indistinguishable from the lazy fix and the next reader cannot tell which one they are looking at.
Related
- Async Behaviour and Timers — the parent section covering awaited queries, fake timers and cleanup
- Isolation Principles — removing the ambient state that makes timing bugs order-dependent
- Mock Boundaries — intercepting the network so promises resolve predictably
- Managing Component State During Automated Tests — the reset-and-flush sequence a deterministic test follows