Fixing React 19 act() Warnings in Tests: Complete Guide
⚠️ Understanding the act() Warning
React 19 introduced stricter enforcement of the act() wrapping requirement. If you're seeing warnings like:
Warning: An update to Component inside a test was not wrapped in act(...).
This means React detected a state update that happened outside of an act() call, which can lead to unpredictable behavior in tests and production.
🔍 What is act() and Why It Matters
The Purpose of act()
act() ensures that all updates related to rendering, user events, or data fetching have been processed and applied to the DOM before your test makes assertions.
// ✅ CORRECT: Wrapping state changes in act()
act(() => {
// Render or update component
render(<MyComponent />);
// Fire events that cause state updates
fireEvent.click(button);
});
// Then make assertions
expect(getByText('Updated')).toBeInTheDocument();
When act() is Required
According to React docs, act() is needed when:
- Rendering a component
- User events (click, change, keyPress, etc.)
- Data fetching that triggers state updates
- Any code that might cause a React state update
🚨 Common Causes of act() Warnings in React 19
1. Missing await on Async Operations
// ❌ PROBLEMATIC: Forgetting await causes state updates after assertion
test('fetches user data', async () => {
render(<UserProfile userId="123" />);
// ❌ Missing await - the fetch promise resolves after expect()
userService.fetchUser('123'); // Returns promise
// State update happens HERE (after expect), outside act()
expect(getByText('Loading...')).toBeInTheDocument();
// Promise resolves here, causing state update outside act()
});
// ✅ SOLUTION: Properly await async operations
test('fetches user data', async () => {
render(<UserProfile userId="123" />);
// ✅ Wait for the promise to resolve
await userService.fetchUser('123');
// State update happens BEFORE expect, within act() (implicitly)
expect(getByText('John Doe')).toBeInTheDocument();
});
2. Improper Mock Timer Usage
// ❌ PROBLEMATIC: Using jest.runAllTimersOutsideAct()
test('shows delayed message', () => {
jest.useFakeTimers();
render(<DelayedMessage />);
// ❌ Running timers outside act() causes state update warnings
jest.runAllTimersOutsideAct(); // This is the problem!
expect(getByText('Hello after delay')).toBeInTheDocument();
});
// ✅ SOLUTION: Use act() with timers
test('shows delayed message', () => {
jest.useFakeTimers();
render(<DelayedMessage />);
// ✅ Wrap timer advancement in act()
act(() => {
jest.advanceTimersByTime(1000);
});
expect(getByText('Hello after delay')).toBeInTheDocument();
// Alternative: Use Testing Library's waitFor
// expect(await findByText('Hello after delay')).toBeInTheDocument();
});
3. Unhandled Promise Rejections
// ❌ PROBLEMATIC: Promise rejection causes microtask state update
test('handles error gracefully', async () => {
render(<ErrorBoundaryComponent />);
// ❌ Unhandled promise rejection
apiService.mockRejectOnce(new Error('Network error'));
// State update from error boundary happens outside act()
expect(getByText('Something went wrong')).toBeInTheDocument();
});
// ✅ SOLUTION: Await the rejection or use waitFor
test('handles error gracefully', async () => {
render(<ErrorBoundaryComponent />);
apiService.mockRejectOnce(new Error('Network error'));
// ✅ Wait for error state to update
await waitFor(() => {
expect(getByText('Something went wrong')).toBeInTheDocument();
});
});
4. Event Listeners Added in useEffect
// ❌ PROBLEMATIC: ResizeObserver causing updates outside act()
test('responds to window resize', () => {
render(<ResponsiveComponent />);
// ❌ Manually triggering resize outside act()
window.dispatchEvent(new Event('resize'));
// State update from ResizeObserver happens outside act()
expect(getByText('Wide screen')).toBeInTheDocument();
});
// ✅ SOLUTION: Wrap event dispatching in act()
test('responds to window resize', () => {
render(<ResponsiveComponent />);
// ✅ Wrap in act()
act(() => {
window.dispatchEvent(new Event('resize'));
});
expect(getByText('Wide screen')).toBeInTheDocument();
});
🛠️ Solutions and Best Practices
Solution 1: Use Testing Library's Built-in Utilities
Testing Library's APIs are designed to work with React's act() requirements:
import { render, screen, waitFor, findByText } from '@testing-library/react';
// ✅ findBy* automatically waits and handles act()
test('finds user after fetch', async () => {
render(<UserProfile userId="123" />);
// ✅ Automatically waits for promise and handles act()
const userName = await findByText(/John Doe/i);
expect(userName).toBeInTheDocument();
});
// ✅ waitFor automatically retries and handles act()
test('shows loading then data', async () => {
render(<DataFetchingComponent />);
// Shows loading state immediately
expect(getByText('Loading…')).toBeInTheDocument();
// ✅ Waits for data to appear, handles act() internally
await waitFor(() => {
expect(getByText('Data loaded')).toBeInTheDocument();
});
});
Solution 2: Create Custom act() Wrappers
For complex scenarios, create reusable act() wrappers:
// test-utils.js
import { act } from 'react-dom/test-utils';
export async function actAsync(asyncFn) {
return await act(asyncFn);
}
export function actWaitFor(callback, options = {}) {
return act(() => {
// This assumes you have waitFor imported
return waitFor(callback, options);
});
}
// Usage in test:
import { actAsync, actWaitFor } from './test-utils';
test('complex async interaction', async () => {
render(<ComplexComponent />);
// ✅ Wrap async function calls
await actAsync(async () => {
await userService.updateProfile({ name: 'John' });
await preferencesService.saveTheme('dark');
});
// ✅ Combine act with waitFor
await actWaitFor(() => {
expect(getByText('Profile updated')).toBeInTheDocument();
expect(getByText('Theme: dark')).toBeInTheDocument();
});
});
Solution 3: Use React's act() Directly for Synchronous Updates
import { act } from 'react-dom/test-utils';
test('updates state synchronously', () => {
render(<Counter />);
// ✅ Synchronous updates need explicit act()
act(() => {
fireEvent.click(getByLabelText('Increment'));
fireEvent.click(getByLabelText('Increment'));
});
expect(getByDisplayValue('2')).toBeInTheDocument();
});
Solution 4: Handle Animation Frame Updates
// ❌ PROBLEMATIC: requestAnimationFrame causing warnings
test('animates element', () => {
render(<AnimatedComponent />);
// ❌ Triggering animation outside act()
// Some animation library uses requestAnimationFrame internally
animation.start();
// State update from animation frame happens outside act()
expect(getByText('Animated')).toHaveStyle('opacity: 1');
});
// ✅ SOLUTION: Use act() with animation frame polyfill or wait
test('animates element', () => {
render(<AnimatedComponent />);
// ✅ Mock requestAnimationFrame to behave synchronously in act()
const originalRaf = window.requestAnimationFrame;
let rafCallbacks = [];
window.requestAnimationFrame = (callback) => {
rafCallbacks.push(callback);
return 0; // Return fake ID
};
window.cancelAnimationFrame = () => {};
try {
render(<AnimatedComponent />);
// ✅ Wrap animation triggering
act(() => {
animation.start();
// Flush all RAF callbacks
rafCallbacks.forEach(cb => cb());
rafCallbacks = [];
});
expect(getByText('Animated')).toHaveStyle('opacity: 1');
} finally {
// Restore original
window.requestAnimationFrame = originalRaf;
window.cancelAnimationFrame = originalCaf;
}
});
🧪 Testing Library Specific Solutions
Use async Utilities (Recommended)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
// ✅ userEvent automatically handles act()
test('user types and submits form', async () => {
render(<LoginForm />);
// ✅ userEvent handles act() internally
await userEvent.type(getByLabelText('Email'), 'test@example.com');
await userEvent.type(getByLabelText('Password'), 'secret123');
await userEvent.click(getByRole('button', { name: 'Sign in' }));
// ✅ waitFor handles act() for async assertions
await waitFor(() => {
expect(getByText('Welcome, test@example.com!')).toBeInTheDocument();
});
});
Mock Modules Properly
// ❌ PROBLEMATIC: Improper mocking causes timing issues
jest.mock('./api', () => ({
fetchUser: () => {
// ❌ Returns promise that resolves later
return Promise.resolve({ name: 'John' });
}
}));
test('displays user name', async () => {
render(<UserProfile userId="123" />);
// State update happens when promise resolves
expect(getByText('John')).toBeInTheDocument(); // ❌ Too early!
});
// ✅ SOLUTION: Mock with proper timing or use waitFor
jest.mock('./api', () => ({
fetchUser: () => Promise.resolve({ name: 'John' })
}));
test('displays user name', async () => {
render(<UserProfile userId="123" />);
// ✅ Wait for the data
await waitFor(() => {
expect(getByText('John')).toBeInTheDocument();
});
});
Use fakeTimers Properly
// ❌ PROBLEMATIC: Mixing fake timers and async incorrectly
jest.useFakeTimers();
test('shows message after delay', () => {
render(<DelayedMessage />);
// Advance timers
jest.advanceTimersByTime(1000);
// State update happens now, but might be outside act()
// depending on implementation
expect(getByText('Hello after delay')).toBeInTheDocument();
});
// ✅ SOLUTION: Wrap timer advances in act()
jest.useFakeTimers();
test('shows message after delay', () => {
render(<DelayedMessage />);
// ✅ Wrap in act()
act(() => {
jest.advanceTimersByTime(1000);
});
expect(getByText('Hello after delay')).toBeInTheDocument();
// Cleanup
jest.useRealTimers();
});
🛡️ Prevention Strategies
1. ESLint Plugin for React
Install and configure eslint-plugin-react with the jsx-no-act rule:
// .eslintrc.js
{
"plugins": ["react"],
"rules": {
"react/jsx-no-act": "error"
}
}
2. Testing Patterns Checklist
Before finishing any test, ask:
- [ ] Did I await all promises that cause state updates?
- [ ] Did I wrap all event dispatches in act()?
- [ ] Did I use Testing Library's find*/waitFor for async assertions?
- [ ] Did I wrap timer advancements in act()?
- [ ] Did I handle animation frame updates properly?
- [ ] Are all mocks resolving at the right time?
3. Custom Test Utilities
Create reusable test helpers:
// test/react-utils.js
import { act } from 'react-dom/test-utils';
import { waitFor } from '@testing-library/react';
export const renderWithAct = (ui, options) => {
return act(() => {
return render(ui, options);
});
};
export const fireEventWithAct = (event, ...args) => {
return act(() => {
return fireEvent(event, ...args);
});
};
export const userEventWithAct = async (userEventFn, ...args) => {
return await act(async () => {
return await userEventFn(...args);
});
};
export const waitForWithAct = async (callback, options) => {
return await act(async () => {
return await waitFor(callback, options);
});
};
Then use in tests:
import { renderWithAct, fireEventWithAct, userEventWithAct, waitForWithAct } from './test/react-utils';
test('user login flow', async () => {
const { getByLabelText, getByRole } = renderWithAct(<LoginForm />);
await userEventWithAct(userEvent.type, getByLabelText('Email'), 'test@example.com');
await userEventWithAct(userEvent.type, getByLabelText('Password'), 'secret123');
await fireEventWithAct(getByRole('button', { name: 'Sign in' }), 'click');
await waitForWithAct(() => {
expect(getByText('Welcome, test@example.com!')).toBeInTheDocument();
});
});
🔧 Advanced: Custom act() Implementation for Complex Cases
Sometimes you need fine-grained control:
// For complex state update patterns
function wrapAct(asyncFn) {
return async (...args) => {
let result;
let error;
try {
// Wrap the entire async operation in act()
result = await act(async () => {
return await asyncFn(...args);
});
} catch (err) {
error = err;
}
// If there was an error, re-throw it outside act()
// to avoid confusing stack traces
if (error) {
throw error;
}
return result;
};
}
// Usage:
const safeFetchUser = wrapAct(userService.fetchUser);
test('fetches user safely', async () => {
render(<UserProfile userId="123" />);
// ✅ Safely wrapped - any state updates happen inside act()
await safeFetchUser('123');
expect(getByText('John Doe')).toBeInTheDocument();
});
📊 When You Can Safely Ignore act() Warnings
✅ Generally Safe to Ignore (Rare Cases)
- Warning occurs in a test helper that doesn't affect test outcome
- Warning is from a third-party library outside your control
- Warning happens during test cleanup (after assertions)
- Warning is about a component that's unmounted immediately
⚠️ Investigate These Warnings
- Warning occurs during the main test execution
- Warning is related to your component's behavior
- Warning affects test reliability (flaky tests)
- Warning appears consistently across test runs
❌ Never Ignore These
- Warning is about state updates in your application code during tests
- Warning causes tests to be flaky or unpredictable
- Warning indicates missing assertions (you're not waiting for something)
- Warning occurs in multiple similar tests (systemic issue)
🛠️ Debugging Techniques
1. Log act() Calls
// Temporarily override act to see what's being wrapped
const originalAct = require('react-dom/test-utils').act;
let actCallCount = 0;
jest.spyOn(require('react-dom/test-utils'), 'act').mockImplementation((callback) => {
actCallCount++;
console.log(`act() call #${actCallCount}`);
return originalAct(callback);
});
// Run your test and check the count
2. Track State Updates
// Override setState to track when updates happen
let stateUpdateCount = 0;
const originalSetState = React.Component.prototype.setState;
React.Component.prototype.setState = function(...args) {
stateUpdateCount++;
console.log(`State update #${stateUpdateCount} at:`, new Error().stack.slice(0, 200));
return originalSetState.apply(this, args);
};
// After test, check if stateUpdateCount matches expected act() calls
3. Use react-is-act
# Package that helps identify act() violations
npx react-is-act --testPathPattern="\\.test\\.(js|ts|jsx|tsx)$"
📋 Migration Checklist for Existing Tests
When upgrading to React 19 or fixing existing act() warnings:
✅ For Each Test File
- [ ] Run test and capture all act() warnings
- [ ] For each warning, identify the asynchronous operation
- [ ] Determine if it's missing await, improper timer use, or event issue
- [ ] Apply appropriate fix (await, wrap in act(), use Testing Library async utils)
- [ ] Rerun test to confirm warnings are gone
- [ ] Verify test still passes and tests the same behavior
✅ For Test Suites
- [ ] Configure ESLint to warn about missing act() wrapping
- [ ] Add act() debugging to CI if flaky tests persist
- [ ] Create team guidelines for proper async testing patterns
- [ ] Review and update test utilities to handle act() properly
- [ ] Consider adopting Testing Library if not already using it
🧰 Recommended Testing Setup
Dependencies
{
"devDependencies": {
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0",
"@testing-library/jest-dom": "^6.0.0",
"jest": "^29.0.0"
}
}
jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'],
transform: {
'^.+\\.[tj]sx?$': 'babel-jest',
},
// Helpful for debugging act() issues
verbose: true,
};
ESLint Configuration
{
"extends": [
"plugin:testing-library/react",
"plugin:@testing-library/jest-dom"
],
"plugins": ["react"],
"rules": {
"react/jsx-no-act": "warn",
"testing-library/no-debugging-utils": "warn"
}
}
React 19's stricter act() enforcement helps catch timing issues that could lead to flaky tests and unpredictable behavior. By using Testing Library's built-in async utilities and properly wrapping state-changing code in act(), you can eliminate these warnings while writing more reliable tests.