How to Test React Concurrent Mode
Testing Concurrent Mode (and concurrent features like transitions and
Suspense) in React requires adapting to how the library schedules,
prioritizes, and interrupts state updates. This guide explains how to
effectively test concurrent behaviors using React Testing Library and
React’s act utility. You will learn how to configure your
testing environment, write tests for transitions, and handle
asynchronous Suspense boundaries to ensure your UI remains stable and
responsive.
1. Ensure Your Environment Supports Concurrent Features
To test concurrent features, your testing environment must use React
18 (or newer) and render components using the createRoot
API. Modern versions of @testing-library/react (version 13
and above) automatically use createRoot under the hood.
Make sure your test setup does not disable concurrent rendering. If you are using React Testing Library, standard rendering is already concurrent-ready:
import { render } from '@testing-library/react';
import MyConcurrentComponent from './MyConcurrentComponent';
test('renders correctly', () => {
render(<MyConcurrentComponent />);
});2. Testing Transitions
(useTransition)
The useTransition hook allows you to mark state updates
as non-urgent transitions, preventing slow renders from freezing the UI.
When testing transitions, you need to assert against both the
intermediate “pending” state and the final “completed” state.
Because transition updates are wrapped in
startTransition, they are non-blocking. You must use
asynchronous query methods like findBy or
waitFor to allow React to finish processing the
low-priority update.
Example Component:
import { useState, useTransition } from 'react';
export function SearchList({ items }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // Urgent update
startTransition(() => {
// Non-urgent update
const filtered = items.filter(item => item.includes(value));
setFilteredItems(filtered);
});
};
return (
<div>
<input type="text" value={query} onChange={handleChange} placeholder="Search..." />
{isPending && <p>Loading updates...</p>}
<ul>
{filteredItems.map(item => <li key={item}>{item}</li>)}
</ul>
</div>
);
}Example Test:
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SearchList } from './SearchList';
test('should show pending state and then render filtered results', async () => {
const items = ['Apple', 'Banana', 'Cherry'];
render(<SearchList items={items} />);
const input = screen.getByPlaceholderText('Search...');
// Trigger transition input
fireEvent.change(input, { target: { value: 'Banana' } });
// 1. Assert the immediate/pending state (if processing takes time)
expect(screen.getByText('Loading updates...')).toBeInTheDocument();
// 2. Assert the final state after transition completes
await waitFor(() => {
expect(screen.queryByText('Loading updates...')).not.toBeInTheDocument();
});
expect(screen.getByText('Banana')).toBeInTheDocument();
expect(screen.queryByText('Apple')).not.toBeInTheDocument();
});3. Testing Suspense Boundaries
Concurrent mode relies heavily on <Suspense> to
handle loading states for data fetching or code splitting. When testing
components that suspend, your tests must handle the transition from the
fallback spinner to the fully loaded UI.
Example Test for Suspense:
import { render, screen } from '@testing-library/react';
import React, { Suspense } from 'react';
const LazyComponent = React.lazy(() => import('./LazyComponent'));
test('renders fallback and then resolves the lazy component', async () => {
render(
<Suspense fallback={<div>Loading component...</div>}>
<LazyComponent />
</Suspense>
);
// Assert fallback is visible
expect(screen.getByText('Loading component...')).toBeInTheDocument();
// Wait for the lazy component to resolve and render
const resolvedElement = await screen.findByText('Loaded Content', {}, { timeout: 2000 });
expect(resolvedElement).toBeInTheDocument();
// Confirm fallback is removed
expect(screen.queryByText('Loading component...')).not.toBeInTheDocument();
});4. Proper Use of
act() for Asynchronous Rendering
React Testing Library automatically wraps user events (like
fireEvent and userEvent) inside
act(). However, when testing custom hooks or complex
concurrent flows where updates occur asynchronously outside standard DOM
events, you may need to wrap updates manually.
When dealing with concurrent features, ensure you use
await act(async () => ...) to let React flush the
microtask queue:
import { renderHook, act } from '@testing-library/react';
import { useTransitionHook } from './useTransitionHook';
test('should handle hook transition', async () => {
const { result } = renderHook(() => useTransitionHook());
await act(async () => {
result.current.triggerTransition('new-value');
});
expect(result.current.value).toBe('new-value');
});Best Practices for Concurrent Testing
- Avoid hardcoded timeouts: Do not use arbitrary
delays like
setTimeoutin your tests. Instead, rely on React Testing Library’sfindByqueries orwaitForhelper to react dynamically when rendering tasks complete. - Enable StrictMode in tests: React’s
StrictModehelps surface concurrent rendering bugs, such as memory leaks or race conditions, by intentionally double-invoking lifecycle methods and effects in your test environment.