Архитектура React 19 вывела асинхронность и работу с конкурентным планировщиком (Concurrent Scheduler) на фундаментально новый уровень. Исторический паттерн «запрос данных внутри useEffect + локальные флаги isLoading/error в useState» признан устаревшим в пользу декларативных примитивов React 19:
use(Promise): Позволяет читать результат выполнения промиса прямо в теле функции компонента во время рендера, автоматически интегрируясь с деревом <Suspense>.useTransition): Позволяют помечать обновления состояния как неблокирующие фоновые переходы, сохраняя интерактивность интерфейса и выставляя флаг isPending.useActionState и useOptimistic): Стандартизируют управление жизненным циклом асинхронных серверных и клиентских действий (Actions), обеспечивая мгновенную обратную связь через оптимистичные обновления.ErrorBoundary): Ловят необработанные исключения и отклоненные промисы (rejected promises), предотвращая падение всего приложения в белый экран.Тестирование асинхронных компонентов React 19 требует понимания механики фаз планировщика: компонент может приостанавливать выполнение (suspend), выбрасывая промис в родительскую границу Suspense, повторно рендериться после разрешения микротаски (settled promise) или выполнять параллельные транзишены без блокировки основного потока.
use(Promise) и сопутствующие механизмы меняют методологию написания тестов: вместо ожидания срабатывания useEffect тест должен управлять жизненным циклом самого промиса.
В момент рендеринга компонента с вызовом use(promise) React проверяет текущий статус объекта Promise. Если промис находится в состоянии pending, React прерывает выполнение Fiber-узла и рендерит fallback-разметку (скелетон или спиннер) ближайшего родительского компонента <Suspense>.
Как только промис переходит в состояние fulfilled, планировщик React 19 планирует повторный рендер компонента с полученным значением. Если же промис отклоняется (rejected), React выбрасывает ошибку в ближайший <ErrorBoundary>.
+-----------------------------------------------------------------------+
| Component Render: const data = use(deferredPromise) |
+-----------------------------------------------------------------------+
│
┌─────────────────────────┴─────────────────────────┐
▼ (Promise: pending) ▼ (Promise: fulfilled)
+----------------------------------+ +------------------------------------+
| Fiber приостанавливает рендер | | Рендерится основной контент UI |
| (Suspends). Отображается | | с полученными данными: |
| <Suspense fallback={<Skeleton/>}>| | screen.getByText(data.title) |
+----------------------------------+ +------------------------------------+
│ ▲
▼ (В тесте: deferred.resolve(payload)) │
+------------------------------------------------------------+
| Разрешение промиса -> React планирует повторный рендер |
+------------------------------------------------------------+Чтобы протестировать как фазу загрузки (loading skeleton), так и фазу отображения данных, тест должен полностью контролировать момент разрешения промиса. Для этого используется фабрика createDeferredPromise<T>():
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 };
}Инициализация Deferred Promise через фабрику createDeferredPromise() с вынесенным resolve/reject.
Рендеринг компонента внутри защитных границ для безопасного перехвата pending и rejected состояний.
Верификация отображения индикатора загрузки через getByRole('status' | 'progressbar').
Ручной вызов deferred.resolve(data) или deferred.reject(error) для смены состояния промиса.
Ожидание исчезновения скелетона (waitForElementToBeRemoved) и проверка рендеринга целевого UI.
Зачем при тестировании компонентов с хуком use(Promise) в React 19 используется паттерн Deferred Promise?
use(Promise) и <Suspense>#Компонент, вызывающий use(), обязан монтироваться внутри <Suspense>:
import { Suspense, use } from 'react';
function UserCard({ userPromise }: { readonly userPromise: Promise<{ name: string }> }) {
const user = use(userPromise);
return <div>Пользователь: {user.name}</div>;
}
// В тесте:
render(
<Suspense fallback={<div role="status">Загрузка...</div>}>
<UserCard userPromise={deferred.promise} />
</Suspense>
);useTransition и isPending#Хук useTransition возвращает кортеж [isPending, startTransition]. В тестах важно проверять промежуточное состояние isPending до завершения микротасок:
const [isPending, startTransition] = useTransition();
// Запуск неблокирующего обновления
startTransition(async () => {
await updateData();
});useActionState (React 19 Actions)#Хук useActionState заменяет устаревший useFormState:
const [state, formAction, isPending] = useActionState(
async (prevState: State, formData: FormData) => {
return await saveArticleAction(formData);
},
initialState
);use(Promise) через Deferred Promise#Тест верифицирует отображение скелетона, затем разрешает промис и проверяет финальный интерфейс.
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="Метрики производительности">
<p>Активные сессии: {data.activeUsers}</p>
<p>Средняя задержка: {data.latencyMs} мс</p>
</article>
);
}
function MetricWidget({ dataPromise }: { readonly dataPromise: Promise<MetricData> }) {
return (
<Suspense fallback={<div role="status">Инициализация метрик...</div>}>
<MetricContent dataPromise={dataPromise} />
</Suspense>
);
}
// Фабрика управляемого промиса
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('отображает fallback скелетон до разрешения промиса и контент после', async () => {
const deferred = createDeferredPromise<MetricData>();
render(<MetricWidget dataPromise={deferred.promise} />);
// 1. Проверяем наличие Suspense fallback
const fallback = screen.getByRole('status');
expect(fallback).toHaveTextContent('Инициализация метрик...');
// Основной регион отсутствует в DOM
expect(
screen.queryByRole('region', { name: 'Метрики производительности' })
).not.toBeInTheDocument();
// 2. Разрешаем промис с полезной нагрузкой
deferred.resolve({ activeUsers: 1420, latencyMs: 14 });
// 3. Ожидаем исчезновения индикатора загрузки
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
// 4. Проверяем рендеринг целевых данных
expect(
screen.getByRole('region', { name: 'Метрики производительности' })
).toBeInTheDocument();
expect(screen.getByText('Активные сессии: 1420')).toBeInTheDocument();
expect(screen.getByText('Средняя задержка: 14 мс')).toBeInTheDocument();
});
});useTransition и состояния isPending#При переключении вкладок или фильтрации таблиц useTransition позволяет отображать индикатор фонового обновления без блокировки основного UI.
import { describe, it, expect } 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="Разделы аналитики">
<button
type="button"
aria-selected={activeTab === 'overview'}
onClick={() => handleSelect('overview')}
>
Обзор
</button>
<button
type="button"
aria-selected={activeTab === 'realtime'}
onClick={() => handleSelect('realtime')}
>
Realtime
</button>
{isPending && <span role="progressbar">Переключение раздела...</span>}
<div role="tabpanel">Текущий раздел: {activeTab}</div>
</nav>
);
}
describe('TabNavigator with useTransition', () => {
it('отображает состояние isPending во время асинхронного перехода', 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('Текущий раздел: overview');
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
// Кликаем по новой вкладке
await user.click(realtimeBtn);
// Во время ожидания промиса активен флаг isPending
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Разрешаем завершение транзишена
resolveTransition();
// Прогрессбар исчезает, таб переключается
await screen.findByText('Текущий раздел: realtime');
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
});useActionState и оптимистичного UI (useOptimistic)#React 19 стандартизировал действия форм. С помощью useOptimistic интерфейс обновляется немедленно до получения подтверждения от сервера.
import { describe, it, expect } 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;
// Оптимистичный таск уже отображается
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>Список задач</h2>
<form action={handleSubmit} aria-label="Добавление задачи">
<input name="title" aria-label="Название новой задачи" />
<button type="submit" disabled={isPending}>
Добавить
</button>
</form>
<ul aria-label="Задачи">
{optimisticTasks.map((t) => (
<li key={t.id} aria-busy={Boolean(t.pending)}>
{t.title} {t.pending && '(Сохранение...)'}
</li>
))}
</ul>
</section>
);
}
describe('TaskTracker with useActionState & useOptimistic', () => {
it('отображает оптимистичный элемент немедленно, а затем обновляет его серверным ответом', 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: 'Изучить React 19' }]}
onAddTask={mockAddTask}
/>
);
const input = screen.getByRole('textbox', { name: 'Название новой задачи' });
const submitBtn = screen.getByRole('button', { name: 'Добавить' });
await user.type(input, 'Написать автотесты');
await user.click(submitBtn);
// 1. Оптимистичное обновление отобразилось синхронно до ответа сервера
expect(
screen.getByText(/Написать автотесты \(Сохранение\.\.\.\)/)
).toBeInTheDocument();
// 2. Разрешаем ответ бэкенда
resolveServerAction({ id: 'srv-99', title: 'Написать автотесты' });
// 3. Статус сохранения исчезает, постоянный таск зафиксирован
const finalItem = await screen.findByText('Написать автотесты');
expect(finalItem).toBeInTheDocument();
expect(screen.queryByText(/Сохранение\.\.\./)).not.toBeInTheDocument();
});
});ErrorBoundary#Когда асинхронный промис в use(Promise) отклоняется с ошибкой или компонент выбрасывает исключение, ErrorBoundary должен отобразить запасной UI, не ломая тест и не засоряя консоль.
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) {
// В реальном приложении: отправка в 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>Данные: {result}</div>;
}
describe('SafeErrorBoundary with Rejected use(Promise)', () => {
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Подавляем вывод React о необработанном исключении в stderr тестового раннера
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
it('перехватывает отклоненный промис use() и рендерит fallback ошибки', async () => {
const rejectedPromise = Promise.reject(new Error('Gateway Timeout 504'));
render(
<SafeErrorBoundary
fallback={(err) => (
<div role="alert">
<h2>Произошла ошибка загрузки</h2>
<p>{err.message}</p>
</div>
)}
>
<Suspense fallback={<div>Загрузка...</div>}>
<BrokenComponent dataPromise={rejectedPromise} />
</Suspense>
</SafeErrorBoundary>
);
// Асинхронно ожидаем, пока Error Boundary перехватит отказ промиса
const alert = await screen.findByRole('alert');
expect(alert).toHaveTextContent('Произошла ошибка загрузки');
expect(alert).toHaveTextContent('Gateway Timeout 504');
});
});Как корректно протестировать перехват ошибок компонентом ErrorBoundary в Vitest без засорения отчетов логами console.error?
Если создавать промис прямо во время выполнения функции (const p = fetch(...)), компонент при каждом перезапуске Suspense будет создавать новый экземпляр промиса. Это приводит к бесконечному циклу рендеринга (Infinite Suspense Loop).
// ❌ АНТИПАТТЕРН: Промис пересоздается при каждом тике рендера!
function BadProfile() {
const data = use(fetch('/api/user')); // Зацикливание!
return <div>{data.name}</div>;
}
// ✅ РЕШЕНИЕ: Промис передается через пропсы или берется из кэша
function GoodProfile({ userPromise }: { readonly userPromise: Promise<User> }) {
const data = use(userPromise);
return <div>{data.name}</div>;
}Если в тесте сразу вызывать screen.getByRole(...) для основного контента без findBy* или waitForElementToBeRemoved, тест упадет, так как React еще находится в фазе приостановки рендера.
// ❌ АНТИПАТТЕРН: Синхронный запрос к еще не отрендеренному содержимому
deferred.resolve({ id: 1 });
expect(screen.getByText('Контент')).toBeInTheDocument(); // Падение!
// ✅ РЕШЕНИЕ: Асинхронное ожидание через findBy или waitForElementToBeRemoved
deferred.resolve({ id: 1 });
await waitForElementToBeRemoved(() => screen.queryByRole('status'));
expect(await screen.findByText('Контент')).toBeInTheDocument();React по умолчанию выводит подробный stack trace ошибки в консоль даже если она была успешно перехвачена componentDidCatch. Это создает ложное впечатление, что тест упал.
// ❌ АНТИПАТТЕРН: В логах CI/CD появляется страшная 'Uncaught error'
render(<SafeErrorBoundary><FailingComponent /></SafeErrorBoundary>);
// ✅ РЕШЕНИЕ: Подавление console.error шпионом vi.spyOn на время теста
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});Переход, обернутый в startTransition(async () => ...), планирует конкурентные низкоприоритетные обновления. Синхронный act не дожидается завершения микротасок внутри транзишена.
// ❌ АНТИПАТТЕРН: Синхронный act не знает о вложенном промисе
act(() => {
startTransition(() => { fetchData(); });
});
// ✅ РЕШЕНИЕ: Использование await act() или ожидание через findBy
await act(async () => {
await triggerAsyncTransition();
});