Testing Modern React: Demystifying act, Async Transitions, and Suspense
Writing automated tests for React applications has historically been fraught with a notorious console warning:
Warning: An update to Component 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 */
});
/* make assertions */
For years, developers treated this warning with dread, often sprinkling random await waitFor(...) blocks or wrapping entire test suites blindly in act() without understanding the root cause. With the introduction of Concurrent React, asynchronous Transitions (useTransition), and Suspense-driven data streaming, unit and integration testing have become even more nuanced.
With React 19, React cleaned up its testing landscape: react-dom/test-utils was deprecated and removed, while the core act utility was officially elevated to a first-class export directly from the react package (React documentation).
In this guide, we demystify how act works, why the "not wrapped in act" warning occurs, how to test asynchronous Transitions, and best practices for writing rock-solid tests in Vitest, Jest, and React Testing Library.
What Does act() Actually Do?
In a real browser, user interactions (clicks, keyboard input) run in discrete task cycles. React processes events, schedules state updates, flushes effects, and updates the DOM before the user perceives the change.
In a headless test environment (Node.js with JSDOM or HappyDOM), there is no true browser event loop orchestrating renders. If an event or resolved Promise triggers a state update, React queues the render asynchronously. If your test immediately executes an assertion (e.g. expect(button).toBeDisabled()), the test checks the DOM before React has had an opportunity to flush its render queue, resulting in false negatives and flakiness.
act() acts as an artificial synchronization boundary:
import { act } from 'react';
await act(async () => {
// 1. Trigger state updates, transitions, or mocked API resolutions
button.click();
});
// 2. React GUARANTEES all scheduled state updates, effects,
// and DOM mutations have completely finished by this line!
expect(screen.getByText('Success')).toBeInTheDocument();
Inside an act block:
- React runs the provided callback.
- React flushes all pending synchronous and asynchronous state updates.
- React flushes all layout effects (
useLayoutEffect). - React executes all passive effects (
useEffect). actonly returns control to your test once the DOM accurately mirrors the final application state.
Why the "Not Wrapped in act(...)" Warning Fires
The infamous warning fires whenever a component schedules a state update after the test's synchronous execution turn has completed:
// ❌ FAILS: The API promise resolves outside of React's awareness
it('fetches and displays user data', async () => {
render(<UserProfile userId="1" />); // Initial render inside act
// UserProfile initiates fetch('/api/user') inside useEffect
// When fetch resolves 20ms later, it calls setUser(data)
// But the test did not wait for it!
// React logs: "Warning: An update to UserProfile was not wrapped in act(...)"
});
The warning is not saying "you forgot to type act()"; it is warning you: "A state update happened after your test thought it was done. You likely have an unawaited asynchronous operation or an assertion running prematurely."
Modern Testing Patterns: React Testing Library Integration
If you use React Testing Library (@testing-library/react), you rarely need to invoke act() manually. RTL's helpers—such as render, fireEvent, screen.findBy*, and waitFor—are already wrapped in act() under the hood.
To eliminate act warnings cleanly, use @testing-library/user-event and findBy* queries:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { NewsletterForm } from './NewsletterForm';
test('submits email and displays confirmation', async () => {
const user = userEvent.setup(); // UserEvent automatically manages act() boundaries
render(<NewsletterForm />);
const input = screen.getByPlaceholderText(/enter email/i);
const submitBtn = screen.getByRole('button', { name: /subscribe/i });
await user.type(input, 'alex@example.com');
await user.click(submitBtn);
// findBy queries poll the DOM within an act() wrapper until the element appears
const successMessage = await screen.findByText(/thank you for subscribing/i);
expect(successMessage).toBeInTheDocument();
});
Testing Asynchronous Transitions (useTransition) in React 19
In React 19, Transitions can be asynchronous functions (React documentation). When testing components that use useTransition, you must ensure the transition is fully awaited:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react';
import { TabSwitcher } from './TabSwitcher';
test('switches tabs under a transition without blocking', async () => {
const user = userEvent.setup();
render(<TabSwitcher />);
const analyticsTabBtn = screen.getByRole('button', { name: /analytics/i });
// Use await act() when driving complex async transition lifecycles manually
await act(async () => {
await user.click(analyticsTabBtn);
});
expect(screen.getByRole('heading', { name: /analytics dashboard/i })).toBeInTheDocument();
});
Testing Suspense and Streaming Boundaries
Testing components wrapped in <Suspense> (React documentation) requires verifying both the fallback state and the resolved data state:
import { render, screen } from '@testing-library/react';
import { Suspense } from 'react';
import { CommentsList } from './CommentsList';
// Mocking an async resource
let resolveComments: (data: string[]) => void;
const commentsPromise = new Promise<string[]>(resolve => {
resolveComments = resolve;
});
function MockComments() {
return (
<Suspense fallback={<div>Loading comments...</div>}>
<CommentsList promise={commentsPromise} />
</Suspense>
);
}
test('renders Suspense fallback and then content upon resolution', async () => {
render(<MockComments />);
// 1. Verify fallback is displayed while Promise is pending
expect(screen.getByText('Loading comments...')).toBeInTheDocument();
// 2. Resolve the promise inside act()
await act(async () => {
resolveComments(['Great post!', 'Very informative.']);
});
// 3. Fallback disappears and real comments appear
expect(screen.queryByText('Loading comments...')).not.toBeInTheDocument();
expect(screen.getByText('Great post!')).toBeInTheDocument();
});
Common Testing Pitfalls to Avoid
- Blindly Wrapping Everything in
act(): Wrapping entire test blocks inact()masks underlying timing issues and leads to verbose, unreadable tests. Letuser-eventandfindBy*queries handle it. - Missing
awaiton Asyncact: Callingact(async () => ...)withoutawaitimmediately voids the synchronization contract, letting assertions run while the microtask queue is still executing. - Uncleaned Mock Timers: If your test uses
vi.useFakeTimers(), ensure you runact(() => { vi.runAllTimers(); })before making assertions.
Conclusion
Testing in modern React does not have to be an exercise in wrestling with console warnings. By understanding act as an explicit flush boundary, leveraging @testing-library/user-event, and handling asynchronous transitions and Suspense promises deliberately, you can write resilient, deterministic test suites that validate user flows with confidence.