Архітектура 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 };
}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, 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="Розділи аналітики">
<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, 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;
// Оптимістичний таск уже відображається
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');
});
});Якщо створювати проміс прямо під час виконання функції (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();
});Ініціалізація Deferred Promise через фабрику createDeferredPromise() з винесеним resolve/reject.
Рендеринг компонента всередині захисних меж для безпечного перехоплення pending та rejected станів.
Верифікація відображення індикатора завантаження через getByRole('status' | 'progressbar').
Ручний виклик deferred.resolve(data) або deferred.reject(error) для зміни стану промісу.
Очікування зникнення скелетона (waitForElementToBeRemoved) та перевірка рендерингу цільового UI.