Fix 'Warning: An update to inside a test was not wrapped in act(...)' in React Testing

intermediateโš›๏ธ React2026-04-07| React 16.8+, React Testing Library 8+, Jest, Node.js

Error Message

Warning: An update to ForwardRef inside a test was not wrapped in act(...). When testing, code that causes React state updates should be wrapped into act(...).
#react#testing#act#react-testing-library#jest

The Error

You write a test, it passes, but the console prints this:

Warning: An update to ForwardRef inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...).
    at ForwardRef
    at YourComponent

Green checkmark, red flag. The test passes, but React is telling you it finished processing state after your assertion already ran. That's a race condition hiding in plain sight โ€” and it produces flaky tests that fail without obvious reason on CI.

Why This Happens

act() is React's test utility for flushing state updates, effects, and re-renders before you assert anything. Think of it as a drain โ€” it empties the queue so you're asserting on a stable snapshot.

The warning fires when something triggers a state update outside that flush cycle. Typical culprits:

  • Async state updates โ€” data fetching, setTimeout, setInterval
  • Event handlers that update state but aren't awaited in the test
  • Third-party components using forwardRef or controlled inputs internally
  • fireEvent calls followed by synchronous assertions on async state
  • useEffect hooks that kick off async work on mount

Reproduce the Problem

Here's the minimal case. A component that fetches a user name on mount:

// UserProfile.tsx
import { useState, useEffect } from 'react';

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<string | null>(null);

  useEffect(() => {
    fetchUser(userId).then((name) => setUser(name)); // async state update
  }, [userId]);

  return <div>{user ?? 'Loading...'}</div>;
}

async function fetchUser(id: string): Promise<string> {
  return Promise.resolve('Alice');
}
// UserProfile.test.tsx โ€” BROKEN
import { render, screen } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('renders user name', () => {
  render(<UserProfile userId="1" />);
  // fetchUser resolves AFTER this line runs,
  // so setUser fires outside act()
  expect(screen.getByText('Loading...')).toBeInTheDocument();
});
// โ†’ Warning: An update to ... inside a test was not wrapped in act(...)

Fix 1: Use waitFor or findBy* (Recommended)

React Testing Library's async utilities wrap themselves in act() for you. Reach for these before anything else.

// UserProfile.test.tsx โ€” FIXED
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';

test('renders user name after fetch', async () => {
  render(<UserProfile userId="1" />);

  // waitFor retries the assertion every 50ms (up to 1000ms by default)
  // and handles act() wrapping internally
  await waitFor(() => {
    expect(screen.getByText('Alice')).toBeInTheDocument();
  });
});

Prefer findBy* when you just need to wait for a single element โ€” it's waitFor + getBy* in one call:

test('renders user name after fetch', async () => {
  render(<UserProfile userId="1" />);

  const name = await screen.findByText('Alice'); // polls until found, act() handled automatically
  expect(name).toBeInTheDocument();
});

Fix 2: Mock the Async Call and Flush Manually

Sometimes you need tighter control โ€” for example, asserting intermediate loading states. Mock the function and drain the microtask queue with act():

import { render, screen, act } from '@testing-library/react';
import { UserProfile } from './UserProfile';
import * as api from './api';

jest.mock('./api');

test('renders user name', async () => {
  jest.spyOn(api, 'fetchUser').mockResolvedValue('Alice');

  render(<UserProfile userId="1" />);

  await act(async () => {
    await Promise.resolve(); // one tick flushes all pending microtasks
  });

  expect(screen.getByText('Alice')).toBeInTheDocument();
});

Fix 3: Wrap User Events Correctly

Button clicks and form submissions that trigger async state are another common source. fireEvent is synchronous โ€” it dispatches the event but doesn't wait for the resulting state update to land.

// BAD โ€” state update after click may not be flushed
fireEvent.click(button);
expect(screen.getByText('Saved')).toBeInTheDocument(); // may warn

// GOOD โ€” userEvent handles act() wrapping and simulates real browser behavior
import userEvent from '@testing-library/user-event';

test('saves form', async () => {
  const user = userEvent.setup();
  render(<MyForm />);

  await user.click(screen.getByRole('button', { name: /save/i }));

  expect(screen.getByText('Saved')).toBeInTheDocument();
});

Default to userEvent for anything that simulates real user interaction. Reserve fireEvent for simple, synchronous DOM events where no state update follows.

Fix 4: Handle setTimeout and Timers

Components that delay state updates via setTimeout need Jest's fake timers. Advance them inside act() so React processes the resulting state change before you assert:

jest.useFakeTimers();

test('shows message after 2-second delay', () => {
  render(<DelayedMessage />);

  act(() => {
    jest.runAllTimers(); // jumps the clock forward, triggers pending callbacks
  });

  expect(screen.getByText('Hello!')).toBeInTheDocument();
});

jest.useRealTimers();

Fix 5: Suppress Warnings from Third-Party Libraries

Date pickers, modals, and animation libraries sometimes trigger the warning internally โ€” in code you don't own and can't patch. If you've confirmed your test logic is sound, scope the suppression to that file only:

const originalError = console.error;
beforeAll(() => {
  jest.spyOn(console, 'error').mockImplementation((...args) => {
    if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return;
    originalError(...args);
  });
});

afterAll(() => {
  (console.error as jest.Mock).mockRestore();
});

Last resort only. Check the library's docs for a test setup guide first โ€” most popular packages document this.

Harden Your Test Suite: Turn Warnings into Errors

Run tests in verbose mode to spot lingering warnings:

npx jest --verbose 2>&1 | grep -i 'act\|warning'
# Clean output means no act() warnings remain

To make the warning a hard test failure โ€” so nothing slips through โ€” add this to your Jest setup file:

// jest.setup.ts
const originalError = console.error.bind(console);
console.error = (...args) => {
  if (
    typeof args[0] === 'string' &&
    args[0].includes('not wrapped in act')
  ) {
    throw new Error(args[0]); // fails the test immediately
  }
  originalError(...args);
};

Wire it up in jest.config.ts:

// jest.config.ts
export default {
  setupFilesAfterEnv: ['./jest.setup.ts'],
};

Quick Decision Guide

  • Component fetches data on mount โ†’ await screen.findBy*() or await waitFor()
  • User interaction triggers async state โ†’ userEvent with await
  • Component uses setTimeout internally โ†’ Jest fake timers inside act()
  • Warning from a library you don't control โ†’ check the library's test docs; suppress only if no fix exists

The Core Idea

React added this warning to catch a specific category of test bug: asserting state before the update cycle finishes. Nine times out of ten, the right move is to make the test async and use RTL's async queries โ€” findBy*, waitFor, userEvent. They already call act() under the hood. Manual act() wrapping exists for edge cases, not the common path.

Related Error Notes