Flaky Tests in React Testing Library: Async Rendering, Queries, and Fixes
React Testing Library has transformed how developers test React components by encouraging tests that mirror real user behavior. Its query-based API, focus on accessibility, and "test the way users interact" philosophy have made it the dominant testing utility for React applications.
But even with the best tooling, react testing library flaky tests remain one of the most frustrating problems in frontend development. Tests that pass locally but fail in CI. Tests that break when you add an unrelated component. Tests that timeout intermittently with no obvious cause.
The root of most flakiness in React Testing Library comes from one fundamental challenge: React's asynchronous rendering model. Components re-render on state changes, effects fire after paint, and data fetching introduces network-dependent timing. Understanding these mechanics is essential to writing stable tests.
This guide covers every major source of react testing library flaky tests, explains the underlying causes, and provides proven patterns to make your component tests deterministic.
Understanding React's Asynchronous Rendering
React 18+ uses concurrent rendering by default, which means updates can be interrupted, prioritized, and batched in ways that were not possible before. This has direct implications for testing.
When you call setState, React does not immediately re-render. Instead, it schedules an update. In concurrent mode, React may even split rendering into multiple chunks. This means:
- A state update in your test does not immediately produce new DOM output.
- Multiple rapid state updates may be batched into a single render.
useEffect) run asynchronously after the browser paints.These behaviors make synchronous assertions unreliable by design.
The getBy vs findBy vs queryBy Decision
The query you choose directly impacts test stability. Using the wrong query type is the number one cause of react testing library flaky tests.
getBy: Synchronous and Immediate
// getBy throws immediately if the element is not in the DOM
const button = screen.getByRole('button', { name: 'Submit' });
Use getBy only when you are certain the element is already rendered. If the element appears after an async operation (data fetch, state update, animation), getBy will throw before it has a chance to appear.
findBy: Async and Retry-Based
// findBy waits up to 1000ms by default, retrying the query
const button = await screen.findByRole('button', { name: 'Submit' });
findBy is shorthand for waitFor(() => getBy(...)). It retries the query until the element appears or the timeout expires. Use this for any element that appears after an async operation.
queryBy: For Asserting Absence
// queryBy returns null instead of throwing
expect(screen.queryByText('Error')).not.toBeInTheDocument();
The Flaky Pattern
// BAD: Using getBy for an element that appears after data fetch
test('displays user profile', async () => {
render( );
// Component fetches data in useEffect, then renders
const name = screen.getByText('Alice'); // THROWS: element not yet rendered
});
// GOOD: Using findBy to wait for async rendering
test('displays user profile', async () => {
render( );
const name = await screen.findByText('Alice'); // Waits for element
expect(name).toBeInTheDocument();
});
waitFor Pitfalls and Best Practices
waitFor is the most powerful tool for handling async behavior in React Testing Library, but it is also the most commonly misused. Misusing waitFor is a leading cause of react testing library flaky tests.
Pitfall 1: Side Effects Inside waitFor
// BAD: Performing actions inside waitFor
await waitFor(() => {
fireEvent.click(screen.getByRole('button')); // Fires on every retry!
expect(screen.getByText('Submitted')).toBeInTheDocument();
});
waitFor retries the callback repeatedly until it passes. If you perform side effects (clicks, API calls) inside the callback, they execute on every retry. This can cause duplicate submissions, state corruption, and unpredictable test behavior.
// GOOD: Separate actions from assertions
fireEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByText('Submitted')).toBeInTheDocument();
});
Pitfall 2: Multiple Assertions in waitFor
// BAD: Multiple independent assertions in one waitFor
await waitFor(() => {
expect(screen.getByText('Name: Alice')).toBeInTheDocument();
expect(screen.getByText('Email: alice@example.com')).toBeInTheDocument();
expect(screen.getByText('Role: Admin')).toBeInTheDocument();
});
If the assertions resolve at different times, waitFor restarts from the first assertion on each retry. This can cause timeouts when later assertions take longer to become true.
// GOOD: Wait for the key indicator, then assert the rest
await screen.findByText('Name: Alice'); // Wait for data to load
expect(screen.getByText('Email: alice@example.com')).toBeInTheDocument();
expect(screen.getByText('Role: Admin')).toBeInTheDocument();
Pitfall 3: Insufficient Timeout
// BAD: Default 1000ms may not be enough for slow async operations
await waitFor(() => {
expect(screen.getByText('Analysis Complete')).toBeInTheDocument();
});
// GOOD: Increase timeout for known slow operations
await waitFor(
() => {
expect(screen.getByText('Analysis Complete')).toBeInTheDocument();
},
{ timeout: 5000 }
);
Pitfall 4: Using waitFor When findBy Suffices
// UNNECESSARY: waitFor wrapping a single query
await waitFor(() => {
expect(screen.getByText('Hello')).toBeInTheDocument();
});
// SIMPLER: Use findBy directly
expect(await screen.findByText('Hello')).toBeInTheDocument();
The act() Warning Problem
The dreaded act() warning is both a symptom and a cause of flaky tests. When you see "An update was not wrapped in act()", it means React processed a state update outside of a testing-aware context.
Why act() Warnings Cause Flakiness
State updates that occur outside act() are not guaranteed to flush before your assertions run. This creates a race condition: sometimes the update completes before the assertion (test passes), sometimes it does not (test fails).
Common Sources of act() Warnings
1. Unresolved async effects after unmount:// BAD: Component fetches data after test ends
test('renders loading state', () => {
render( );
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Test ends, but useEffect's fetch resolves and calls setState
// React warns: state update on unmounted component
});
// GOOD: Wait for the async operation to complete
test('renders loading then data', async () => {
render( );
expect(screen.getByText('Loading...')).toBeInTheDocument();
await screen.findByText('Data loaded'); // Wait for fetch to complete
});
2. Timers and intervals:
// BAD: setInterval fires after test assertion
test('shows countdown', () => {
jest.useFakeTimers();
render( );
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText('7 seconds')).toBeInTheDocument();
jest.useRealTimers();
// Remaining intervals may fire and cause act() warnings
});
// GOOD: Clean up timers properly
test('shows countdown', () => {
jest.useFakeTimers();
const { unmount } = render( );
act(() => {
jest.advanceTimersByTime(3000);
});
expect(screen.getByText('7 seconds')).toBeInTheDocument();
unmount(); // Component cleans up its interval
jest.useRealTimers();
});
Testing Async Components
Modern React applications are full of async patterns: data fetching with useEffect, Suspense boundaries, lazy-loaded components, and server-state libraries like React Query or SWR. Each requires specific testing strategies.
Data Fetching Components
// Component
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUsers().then((data) => {
setUsers(data);
setLoading(false);
});
}, []);
if (loading) return
Loading users...
;
return (
{users.map((u) => (
- {u.name}
))}
);
}
// Test
test('renders user list after fetch', async () => {
// Mock the API
vi.mocked(fetchUsers).mockResolvedValue([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
render( );
// Assert loading state
expect(screen.getByText('Loading users...')).toBeInTheDocument();
// Wait for data
const alice = await screen.findByText('Alice');
expect(alice).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
// Assert loading is gone
expect(screen.queryByText('Loading users...')).not.toBeInTheDocument();
});
React Query / SWR Components
When using server-state libraries, wrap your component in the library's provider with a fresh client for each test:
function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false, // Don't retry in tests
gcTime: 0, // Don't cache between tests
},
},
});
return render(
{ui}
);
}
Reusing a QueryClient across tests is a major source of flakiness because cached data from one test leaks into the next.
Suspense Boundaries
test('renders lazy component', async () => {
render(
Loading...