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