React 19 elevates asynchronous programming and the Concurrent Scheduler to a new architectural standard. The historical pattern of «fetching data inside useEffect + managing local isLoading/error state via useState» has been superseded by declarative primitives in React 19:
use(Promise) Hook: Enables reading promise values directly in the component render phase, seamlessly integrating with the <Suspense> tree.useTransition): Marks state updates as non-blocking background transitions, keeping user interactions responsive while exposing an isPending flag.useActionState and useOptimistic): Standardizes the lifecycle of asynchronous client and server actions with instant user feedback through optimistic mutations.ErrorBoundary): Traps unhandled exceptions and rejected promises, shielding applications from blank screen crashes.Testing React 19 asynchronous components demands understanding concurrent scheduler phases: a component can suspend execution, throwing a promise to the nearest <Suspense> boundary, re-render upon promise settlement, or perform parallel transitions without blocking the main browser thread.
use(Promise) and its companion mechanisms reshape testing workflows: rather than waiting for useEffect side effects, tests directly orchestrate the promise lifecycle.
When rendering a component that executes use(promise), React inspects the promise's internal status. If the promise is pending, React suspends Fiber execution and mounts the fallback UI (skeleton or spinner) declared by the closest <Suspense> boundary.
The instant the promise transitions to fulfilled, the React 19 scheduler queues a re-render of the component with the resolved payload. If the promise transitions to rejected, React throws the error into the nearest <ErrorBoundary>.
+-----------------------------------------------------------------------+
| Component Render: const data = use(deferredPromise) |
+-----------------------------------------------------------------------+
│
┌─────────────────────────┴─────────────────────────┐
▼ (Promise: pending) ▼ (Promise: fulfilled)
+----------------------------------+ +------------------------------------+
| Fiber suspends render. | | Main content UI renders with |
| Fallback mounts: | | resolved data payload: |
| <Suspense fallback={<Skeleton/>}>| | screen.getByText(data.title) |
+----------------------------------+ +------------------------------------+
│ ▲
▼ (In test: deferred.resolve(payload)) │
+------------------------------------------------------------+
| Promise settles -> React schedules re-render |
+------------------------------------------------------------+To verify both the loading skeleton phase and data presentation phase deterministically, tests must control the exact moment of resolution. This is achieved via a createDeferredPromise<T>() factory:
export interface DeferredPromise<T> {
readonly promise: Promise<T>;
readonly resolve: (value: T) => void;
readonly reject: (reason: unknown) => void;
}
export function createDeferredPromise<T>(): DeferredPromise<T> {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}use(Promise) and <Suspense>#A component that invokes use() must mount within a <Suspense> boundary:
import { Suspense, use } from 'react';
function UserCard({ userPromise }: { readonly userPromise: Promise<{ name: string }> }) {
const user = use(userPromise);
return <div>User: {user.name}</div>;
}
// In test:
render(
<Suspense fallback={<div role="status">Loading...</div>}>
<UserCard userPromise={deferred.promise} />
</Suspense>
);useTransition and isPending#The useTransition hook yields an [isPending, startTransition] tuple. Tests must assert the transient isPending state prior to transition resolution:
const [isPending, startTransition] = useTransition();
// Trigger non-blocking update
startTransition(async () => {
await updateData();
});useActionState (React 19 Actions)#The useActionState hook replaces the deprecated useFormState:
const [state, formAction, isPending] = useActionState(
async (prevState: State, formData: FormData) => {
return await saveArticleAction(formData);
},
initialState
);use(Promise) and Deferred Promise#The test verifies skeleton rendering, settles the promise, and asserts target data rendering.
import { describe, it, expect } from 'vitest';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import React, { Suspense, use } from 'react';
interface MetricData {
readonly activeUsers: number;
readonly latencyMs: number;
}
function MetricContent({ dataPromise }: { readonly dataPromise: Promise<MetricData> }) {
const data = use(dataPromise);
return (
<article aria-label="Performance metrics">
<p>Active sessions: {data.activeUsers}</p>
<p>Average latency: {data.latencyMs} ms</p>
</article>
);
}
function MetricWidget({ dataPromise }: { readonly dataPromise: Promise<MetricData> }) {
return (
<Suspense fallback={<div role="status">Initializing metrics...</div>}>
<MetricContent dataPromise={dataPromise} />
</Suspense>
);
}
// Managed promise factory
function createDeferredPromise<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('MetricWidget with React 19 use(Promise)', () => {
it('displays fallback skeleton before promise resolves and content after', async () => {
const deferred = createDeferredPromise<MetricData>();
render(<MetricWidget dataPromise={deferred.promise} />);
// 1. Verify Suspense fallback presence
const fallback = screen.getByRole('status');
expect(fallback).toHaveTextContent('Initializing metrics...');
// Main section must not exist in DOM yet
expect(
screen.queryByRole('region', { name: 'Performance metrics' })
).not.toBeInTheDocument();
// 2. Resolve promise with payload
deferred.resolve({ activeUsers: 1420, latencyMs: 14 });
// 3. Await loader removal from DOM
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
// 4. Assert rendered data
expect(
screen.getByRole('region', { name: 'Performance metrics' })
).toBeInTheDocument();
expect(screen.getByText('Active sessions: 1420')).toBeInTheDocument();
expect(screen.getByText('Average latency: 14 ms')).toBeInTheDocument();
});
});useTransition and isPending#When switching navigation views or applying table filters, useTransition exposes background progress without locking user interactions.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState, useTransition } from 'react';
interface TabProps {
readonly onTabChange: (tab: string) => Promise<void>;
}
function TabNavigator({ onTabChange }: TabProps) {
const [activeTab, setActiveTab] = useState('overview');
const [isPending, startTransition] = useTransition();
const handleSelect = (tab: string) => {
startTransition(async () => {
await onTabChange(tab);
setActiveTab(tab);
});
};
return (
<nav aria-label="Analytics sections">
<button
type="button"
aria-selected={activeTab === 'overview'}
onClick={() => handleSelect('overview')}
>
Overview
</button>
<button
type="button"
aria-selected={activeTab === 'realtime'}
onClick={() => handleSelect('realtime')}
>
Realtime
</button>
{isPending && <span role="progressbar">Switching section...</span>}
<div role="tabpanel">Current section: {activeTab}</div>
</nav>
);
}
describe('TabNavigator with useTransition', () => {
it('displays isPending state during asynchronous transition', async () => {
const user = userEvent.setup();
let resolveTransition!: () => void;
const transitionPromise = new Promise<void>((res) => {
resolveTransition = res;
});
const mockTabChange = vi.fn().mockImplementation(() => transitionPromise);
render(<TabNavigator onTabChange={mockTabChange} />);
const realtimeBtn = screen.getByRole('button', { name: 'Realtime' });
expect(screen.getByRole('tabpanel')).toHaveTextContent('Current section: overview');
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
// Click new tab
await user.click(realtimeBtn);
// During transition execution, progress indicator must mount
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Settle transition promise
resolveTransition();
// Progressbar unmounts, tab switches
await screen.findByText('Current section: realtime');
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});useActionState and Optimistic UI (useOptimistic)#React 19 standardizes form actions. With useOptimistic, the interface updates immediately before server confirmation.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useActionState, useOptimistic } from 'react';
interface Task {
readonly id: string;
readonly title: string;
readonly pending?: boolean;
}
interface TaskTrackerProps {
readonly initialTasks: readonly Task[];
readonly onAddTask: (title: string) => Promise<Task>;
}
function TaskTracker({ initialTasks, onAddTask }: TaskTrackerProps) {
const [tasks, formAction, isPending] = useActionState(
async (prevTasks: readonly Task[], formData: FormData) => {
const title = formData.get('title') as string;
if (!title) return prevTasks;
// Optimistic task is already displayed
const savedTask = await onAddTask(title);
return [...prevTasks, savedTask];
},
initialTasks
);
const [optimisticTasks, setOptimisticTasks] = useOptimistic(
tasks,
(current: readonly Task[], newTitle: string) => [
...current,
{ id: 'temp-id', title: newTitle, pending: true },
]
);
const handleSubmit = (formData: FormData) => {
const title = formData.get('title') as string;
setOptimisticTasks(title);
return formAction(formData);
};
return (
<section>
<h2>Task List</h2>
<form action={handleSubmit} aria-label="Add task form">
<input name="title" aria-label="New task title" />
<button type="submit" disabled={isPending}>
Add
</button>
</form>
<ul aria-label="Tasks">
{optimisticTasks.map((t) => (
<li key={t.id} aria-busy={Boolean(t.pending)}>
{t.title} {t.pending && '(Saving...)'}
</li>
))}
</ul>
</section>
);
}
describe('TaskTracker with useActionState & useOptimistic', () => {
it('renders optimistic item immediately, then commits with server response', async () => {
const user = userEvent.setup();
let resolveServerAction!: (task: Task) => void;
const serverPromise = new Promise<Task>((res) => {
resolveServerAction = res;
});
const mockAddTask = vi.fn().mockImplementation(() => serverPromise);
render(
<TaskTracker
initialTasks={[{ id: '1', title: 'Learn React 19' }]}
onAddTask={mockAddTask}
/>
);
const input = screen.getByRole('textbox', { name: 'New task title' });
const submitBtn = screen.getByRole('button', { name: 'Add' });
await user.type(input, 'Write Automated Tests');
await user.click(submitBtn);
// 1. Optimistic entry appears synchronously prior to server settlement
expect(
screen.getByText(/Write Automated Tests \(Saving\.\.\.\)/)
).toBeInTheDocument();
// 2. Settle server action
resolveServerAction({ id: 'srv-99', title: 'Write Automated Tests' });
// 3. Saving status disappears, persistent record is finalized
const finalItem = await screen.findByText('Write Automated Tests');
expect(finalItem).toBeInTheDocument();
expect(screen.queryByText(/Saving\.\.\./)).not.toBeInTheDocument();
});
});ErrorBoundary#When an asynchronous promise in use(Promise) rejects or a component throws during rendering, an ErrorBoundary must render fallback UI without crashing the test runner or polluting logs.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import React, { Component, Suspense, use, type ReactNode, type ErrorInfo } from 'react';
interface ErrorBoundaryProps {
readonly children: ReactNode;
readonly fallback: (error: Error) => ReactNode;
}
interface ErrorBoundaryState {
readonly error: Error | null;
}
class SafeErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
override state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo) {
// In production: dispatch to telemetry/Sentry
}
override render() {
if (this.state.error) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
function BrokenComponent({ dataPromise }: { readonly dataPromise: Promise<string> }) {
const result = use(dataPromise);
return <div>Data: {result}</div>;
}
describe('SafeErrorBoundary with Rejected use(Promise)', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Silence React uncaught error stack trace in test stderr
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('traps rejected use() promise and displays error fallback', async () => {
const rejectedPromise = Promise.reject(new Error('Gateway Timeout 504'));
render(
<SafeErrorBoundary
fallback={(err) => (
<div role="alert">
<h2>Loading Failure</h2>
<p>{err.message}</p>
</div>
)}
>
<Suspense fallback={<div>Loading...</div>}>
<BrokenComponent dataPromise={rejectedPromise} />
</Suspense>
</SafeErrorBoundary>
);
// Asynchronously await ErrorBoundary catching the rejected promise
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Loading Failure');
expect(alert).toHaveTextContent('Gateway Timeout 504');
});
});Creating promises during render execution (const p = fetch(...)) causes the component to regenerate fresh promise instances on every Suspense retry, triggering an Infinite Suspense Loop.
// ❌ ANTIPATTERN: Promise is regenerated on every render tick!
function BadProfile() {
const data = use(fetch('/api/user')); // Infinite loop!
return <div>{data.name}</div>;
}
// ✅ SOLUTION: Pass promise via props or retrieve from a stable cache
function GoodProfile({ userPromise }: { readonly userPromise: Promise<User> }) {
const data = use(userPromise);
return <div>{data.name}</div>;
}Asserting target elements with synchronous screen.getByRole(...) without findBy* or waitForElementToBeRemoved will fail because React is still in the Suspended render phase.
// ❌ ANTIPATTERN: Synchronous query against unmounted content
deferred.resolve({ id: 1 });
expect(screen.getByText('Content')).toBeInTheDocument(); // Fails!
// ✅ SOLUTION: Async assertion after skeleton unmounts
deferred.resolve({ id: 1 });
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
expect(await screen.findByText('Content')).toBeInTheDocument();React logs caught errors to console.error even when trapped by componentDidCatch. This causes misleading failure reports in CI logs.
// ❌ ANTIPATTERN: Uncaught error trace litters test stdout/stderr
render(<SafeErrorBoundary><FailingComponent /></SafeErrorBoundary>);
// ✅ SOLUTION: Silence console.error using vi.spyOn for the duration of the test
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});A transition wrapped in startTransition(async () => ...) schedules low-priority concurrent updates. Synchronous act does not await nested asynchronous microtasks.
// ❌ ANTIPATTERN: Synchronous act does not wait for transition microtasks
act(() => {
startTransition(() => { fetchData(); });
});
// ✅ SOLUTION: Use await act() or await with findBy
await act(async () => {
await triggerAsyncTransition();
});Initialize a Deferred Promise using createDeferredPromise() with exposed resolve/reject callbacks.
Mount component inside protective boundaries to safely catch pending and rejected states.
Verify loading indicator presence using getByRole('status' | 'progressbar').
Manually invoke deferred.resolve(data) or deferred.reject(error) to transition promise status.
Wait for skeleton removal (waitForElementToBeRemoved) and assert target rendered UI.