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:

  1. Asynchronous Operations Not Awaited:

    • Diagnosis: Your test involves a setTimeout, fetch, or any other asynchronous operation that triggers a state update. You’re not awaiting 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.
  2. 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.
  3. Manual DOM Manipulations Outside act():

    • Diagnosis: Your test directly manipulates the DOM (e.g., using element.innerHTML = ... or document.body.appendChild(...)) in a way that would normally cause a React re-render, but this manipulation happens outside an act() 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.
  4. Incorrectly Mocking useEffect or componentDidMount:

    • Diagnosis: If you’re using jest.spyOn or 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.
  5. Using setState Outside of an Event Handler or Effect:

    • Diagnosis: You’re calling setState directly 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 setState calls that should be part of your test’s flow must be inside an act() 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.
  6. Legacy Testing Library Usage (Less Common Now):

    • Diagnosis: Older versions of @testing-library/react might have had subtle edge cases, or you might be using an older pattern where act wasn’t as seamlessly integrated.
    • Fix: Ensure you’re using the latest versions of @testing-library/react and @testing-library/react-hooks (if applicable), and follow the patterns described above. Most modern usage of fireEvent or userEvent from @testing-library/user-event will automatically wrap asynchronous updates in act.
      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.

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.

Want structured learning?

Take the full React course →