React’s testing utilities are complaining that you’re trying to update the DOM outside of a controlled testing environment, which can lead to flaky tests.
Here’s what’s actually happening: React, in its development mode, has checks in place to ensure that state updates and DOM manipulations during tests are predictable. When you perform an action in your test that causes a state change or a re-render (like a button click or fetching data), and this happens after your test has already finished its initial setup and is no longer "actively" listening for these changes, React flags it. It’s essentially saying, "Hey, I’m seeing a change, but I don’t know if this is part of the test’s intended flow or just some stray side effect." This is problematic because it can lead to race conditions or missed updates, making your tests unreliable.
Common Causes and Fixes:
-
Asynchronous Operations Not Awaited:
- Diagnosis: Your test involves a
setTimeout,fetch, or any other asynchronous operation that triggers a state update. You’re notawaiting the completion of this operation before the test ends. - Fix: Wrap your asynchronous operation that causes the state update within
act().import { render, screen, act } from '@testing-library/react'; test('updates after async operation', async () => { render(<MyComponent />); await act(async () => { await new Promise(resolve => setTimeout(resolve, 100)); // Simulate async delay }); // Assertions here }); - Why it works:
act()ensures that all updates triggered by the asynchronous operation are flushed and applied before the test proceeds, mimicking how React behaves in a real browser environment.
- Diagnosis: Your test involves a
-
Multiple State Updates in a Single Event Handler:
- Diagnosis: An event handler in your component performs several state updates in quick succession. If these updates aren’t batched correctly by React’s testing utilities, the warning can appear.
- Fix: Ensure all synchronous state updates within an event handler are wrapped in
act().import { render, screen, act } from '@testing-library/react'; test('handles multiple updates', () => { render(<MyComponent />); const button = screen.getByRole('button'); act(() => { fireEvent.click(button); // Assuming this click triggers multiple setStates }); // Assertions here }); - Why it works:
act()guarantees that all state updates and subsequent re-renders triggered by the event handler are completed and applied before the test moves on.
-
Manual DOM Manipulations Outside
act():- Diagnosis: Your test directly manipulates the DOM (e.g., using
element.innerHTML = ...ordocument.body.appendChild(...)) in a way that would normally cause a React re-render, but this manipulation happens outside anact()block. - Fix: If you must manually manipulate the DOM in a test that affects your React component’s rendering, do it within
act().import { render, screen, act } from '@testing-library/react'; test('handles manual DOM change', () => { const { container } = render(<MyComponent />); act(() => { // Example: Directly changing a prop on a child element if absolutely necessary // For most cases, avoid this and let React manage the DOM. const childElement = container.querySelector('.child'); if (childElement) { childElement.textContent = 'New Text'; } }); // Assertions here }); - Why it works:
act()tells React to process any pending effects or updates that might have been triggered by your manual DOM change, ensuring consistency. However, it’s generally better to avoid direct DOM manipulation and let React handle it.
- Diagnosis: Your test directly manipulates the DOM (e.g., using
-
Incorrectly Mocking
useEffectorcomponentDidMount:- Diagnosis: If you’re using
jest.spyOnor other mocking techniques for lifecycle methods or hooks that trigger state updates, and the mock’s implementation isn’t properly wrapped or awaited, you might see this warning. - Fix: Ensure that any mocked asynchronous logic within lifecycle methods or hooks that leads to state updates is also managed by
act().import { render, screen, act } from '@testing-library/react'; import MyComponent from '../MyComponent'; test('mocked effect update', async () => { const mockFetch = jest.fn().mockResolvedValue({ json: () => ({ data: 'test' }) }); global.fetch = mockFetch; render(<MyComponent />); await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); // Allow useEffect to run }); // Assertions }); - Why it works:
act()ensures that the asynchronous work within the mocked effect is completed and its resulting state updates are processed before the test continues.
- Diagnosis: If you’re using
-
Using
setStateOutside of an Event Handler or Effect:- Diagnosis: You’re calling
setStatedirectly in the top-level scope of your test file or in a place that React’s testing utilities can’t associate with an ongoing update cycle. - Fix: Any
setStatecalls that should be part of your test’s flow must be inside anact()block.import { render, screen, act } from '@testing-library/react'; test('direct setState in act', () => { const { rerender } = render(<MyComponent />); act(() => { // Assume MyComponent has a prop that causes a state update when changed rerender(<MyComponent prop="new value" />); }); // Assertions }); - Why it works:
act()creates a boundary for React to process state updates, ensuring that the component re-renders and the DOM is updated consistently.
- Diagnosis: You’re calling
-
Legacy Testing Library Usage (Less Common Now):
- Diagnosis: Older versions of
@testing-library/reactmight have had subtle edge cases, or you might be using an older pattern whereactwasn’t as seamlessly integrated. - Fix: Ensure you’re using the latest versions of
@testing-library/reactand@testing-library/react-hooks(if applicable), and follow the patterns described above. Most modern usage offireEventoruserEventfrom@testing-library/user-eventwill automatically wrap asynchronous updates inact.import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; test('userEvent handles act automatically', async () => { render(<MyComponent />); const button = screen.getByRole('button'); await userEvent.click(button); // userEvent handles act() internally for async updates // Assertions }); - Why it works: Modern testing library utilities are designed to abstract away the manual
act()calls for common asynchronous interactions, making your tests cleaner and less prone to this warning.
- Diagnosis: Older versions of
If you’ve applied act() correctly to all your asynchronous operations and event handlers that cause state updates, and you’re still seeing the warning, double-check that you haven’t inadvertently called setState or triggered a re-render in a part of your test that is outside of any act() block.
The next error you’ll likely hit is a "Test finished with pending timers" warning if you’ve used setTimeout or setInterval and haven’t cleared them, or if your asynchronous operations are still not fully resolved.