DEV Community

Hodeem
Hodeem

Posted on

`act` vs. `waitFor`

Introduction

After reviewing posts about @testing-library/react on forums, it became clear that some developers struggle to understand what act does (and doesn't do), when to use it, what the root cause of that annoying warning is, and when to use act vs waitFor. This blog post aims to address that.

act

From the React docs,

`act` is a test helper to apply pending 
React updates before making assertions.
Enter fullscreen mode Exit fullscreen mode

The act function from @testing-library/react is a simple wrapper around the act function from React.
The name of the function comes from the Arrange-Act-Assert pattern of writing tests. act serves to prevent tests from asserting on incomplete renders before all updates have finished.

The React docs also recommend using act with await and an async function callback.

Let's demonstrate the use of act by writing a test for a simple custom hook.

// useCounter.ts
import { useState } from 'react';

export function useCounter() {
  const [count, setCount] = useState(0);
  const increment = () => setCount((c) => c + 1);

  return { count, increment };
}
Enter fullscreen mode Exit fullscreen mode
// useCounter.test.tsx
  test("counter increments (Fails without act)", () => {
  const { result } = renderHook(() => useCounter());

  // Throws the "not wrapped in act(...)" warning
  result.current.increment();

  // ❌ Test fails 
  expect(result.current.count).toBe(1);
});
Enter fullscreen mode Exit fullscreen mode

Here's an example of how act would be used to solve this:

// useCounter.test.tsx
test("counter increments (Passes with act)", async() => {
  const { result } = renderHook(() => useCounter());

  // Wrap the direct state-changing method in act
  await act(async () => {
    result.current.increment();
  });

  // React has now flushed the updates safely
  // ✅ Test passes
  expect(result.current.count).toBe(1);
});
Enter fullscreen mode Exit fullscreen mode

So, the callback passed to act will be executed first and then React updates will be flushed before the test continues. Failure to wrap the action in act could result in React state changes taking place after the assertion runs, which results in the dreaded warning:

Warning: An update to Counter inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):

act(() => {
  /* fire events that update state */
});
/* assert on results */

This ensures that the behavior matches how React works in the browser.
    at Counter
Enter fullscreen mode Exit fullscreen mode

What act isn't

I believe the confusion comes from the phrase "React updates" and I've found that the simplest way to clear things up is to identify what doesn't count as a "React update". Here are two of the most common browser APIs that aren't "React updates", and so aren't directly controlled by act.

Fetch requests

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

export function UserProfile() {
  const [name, setName] = useState<string>('Loading...');

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/users/1')
      .then((response) => response.json())
      .then((data) => {
        setName(data.name);
      })
      .catch(() => {
        setName('Failed to load user');
      });
  }, []);

  return (
    <div>
      <h1>User Profile</h1>
      <p data-testid="user-name">{name}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode
// UserProfile.test.tsx
test("proving act cannot block or control real network requests", () => {
  // Uses `act` internally, so we don't need to wrap this in `act()`
  render(<UserProfile />);

  const nameDisplay = screen.getByTestId("user-name");

  // ❌ Test fails
  expect(nameDisplay).not.toHaveTextContent("Loading...");
});
Enter fullscreen mode Exit fullscreen mode

Fetch requests are sent to the networking thread, which is outside of React's scheduling system. So, invoking act won't immediately resolve the request. This test fails because when the assertion is executed, the network request is still pending. So, the DOM is still in its initial state.

Timers

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

export function DelayedMessage() {
  const [message, setMessage] = useState('Waiting...');

  useEffect(() => {
    setTimeout(() => {
      setMessage('Hello World!');
    }, 1000); 
  }, []);

  return <p data-testid="message">{message}</p>;
}
Enter fullscreen mode Exit fullscreen mode
// DelayedMessage.test.tsx
test("proving act cannot fast-forward native setTimeouts", () => {
  render(<DelayedMessage />);

  const messageDisplay = screen.getByTestId("message");

  // ❌ Test fails
  expect(messageDisplay.textContent).not.toBe("Waiting...");
});
Enter fullscreen mode Exit fullscreen mode

This test fails because timers aren't controlled by React. So, act cannot force the clock to jump forward by default.

"React updates" are things like state updates and effects, not browser or runtime APIs, so understanding which APIs are provided by the browser or runtime and which are provided by React can save you time and energy.

Mocking is part of the solution for working with non-React APIs, but mocking alone is often not enough. The next section will show some simple approaches for handling external APIs.

waitFor

The waitFor function is used to retry any callback until it stops throwing. It's typically used to wait for assertions to pass without knowing when they'll be ready. Use it for asynchronous side-effects and network requests. The waitFor function repeatedly executes the callback passed to it, until either the callback succeeds or the timeout is exceeded.

await waitFor(() => {
    expect(messageDisplay.textContent).toBe("Hello World!");
  });
Enter fullscreen mode Exit fullscreen mode

You also have the option to configure the timeout and delay between retries.

await waitFor(
    () => {
      expect(messageDisplay.textContent).toBe("Hello World!");
    },
    { 
      timeout: 3000, // Tells RTL to keep retrying for up to 3 seconds before failing
      interval: 100   // Optional: Checks the DOM every 100ms (default is 50ms)
    }
  );
Enter fullscreen mode Exit fullscreen mode

Like render, waitFor also wraps act. Query methods, such as findBy*, wrap waitFor. getBy* and queryBy* don't. This will explain why you don't see waitFor and act in many tests.

Here's how we can use mocking and waitFor to fix the tests shown earlier:

For fetch requests:

// UserProfile.test.tsx
test("renders user name after mock fetch resolves", async () => {
  // Mock global fetch before rendering the component
  const mockFetch = vi.fn().mockResolvedValue({
    json: () => Promise.resolve({ name: "John Doe" }),
  });
  vi.stubGlobal("fetch", mockFetch);

  render(<UserProfile />);

  // Uses `waitFor` internally, so we don't need to wrap this in `waitFor()`
  const nameDisplay = await screen.findByText("John Doe");

  // ✅ Test passes
  expect(nameDisplay).toBeInTheDocument();
  expect(nameDisplay).not.toHaveTextContent("Loading...");

  // Clean up the global mock after the test finishes
  vi.unstubAllGlobals();
});
Enter fullscreen mode Exit fullscreen mode

And for timers like setTimeout:

// DelayedMessage.test.tsx
test("renders message after fake timers advance", async () => {
  // Tell Vitest to hijack the native browser clock
  vi.useFakeTimers();

  render(<DelayedMessage />);

  const messageDisplay = screen.getByTestId("message");

  // Verify the initial state before the timer fires
  expect(messageDisplay).toHaveTextContent("Waiting...");

  // Advancing time by 1000ms triggers a React state update, 
  // so this action must be wrapped in act().
  await act(async () => {
    vi.advanceTimersByTime(1000);
  });

  // ✅ Test passes
  expect(messageDisplay).not.toHaveTextContent("Waiting...");
  expect(messageDisplay).toHaveTextContent("Hello World!");

  // Clean up and restore the real system clock
  vi.useRealTimers();
});
Enter fullscreen mode Exit fullscreen mode

Note: The examples use vi from vitest, but the same principles apply to tests written with jest.

Conclusion

If you get confused about when and where to use act or waitFor, it helps to remember the following rules of thumb:

  1. If we're performing an action and we require React updates to complete before proceeding, use act (or a function that wraps it).
  2. If we're waiting for an async update, or if we need to retry a condition until it becomes true, use waitFor (or a function that wraps it).
  3. If the update relies on a non-React API, act may not be enough. Consider mocking.

Sources

Further Reading

Top comments (0)