У ранніх версіях React робота з формами перетворилася на одну з найбільш перевантажених областей архітектури. Стандартним підходом були "контрольовані компоненти" (Controlled Components), за яких кожне натискання клавіші викликало подію onChange, оновлювало локальний стан через useState та ініціювало повний повторний рендеринг компонента.
У великих enterprise-застосунках це вимагало підключення важких сторонніх бібліотек (Formik, React Hook Form), провокувало надмірне виділення пам'яті та повністю порушувало концепцію Progressive Enhancement — без завантаження клієнтського JS-бандла форма залишалася абсолютно мертвою.
React 19 здійснює фундаментальний архітектурний поворот до стандартів веб-платформи, представляючи нативну парадигму React Actions. Екшени — це асинхронні функції переходу (Transitions), глибоко інтегровані з нативними HTML-елементами <form>, <input> та <button>.
Під капотом React 19 автоматизує чотири критичні аспекти життєвого циклу мутації даних:
isPending та контекстний хук useFormStatus для вкладених компонентів.У парадигмі React 19 форма розглядається як декларативний скінченний автомат. Обробник action приймає не синтетичну подію React.FormEvent, а нативний екземпляр FormData (або довільні аргументи) і автоматично виконується в контексті низькопріоритетного переходу TransitionLane.
[ Користувач натискає "Надіслати" ]
│
▼
[ Диспетчеризація Action (useActionState) ]
│
├───► [ useOptimistic: UI миттєво мутує в очікуваний вигляд ]
│
├───► [ useFormStatus: Кнопка сабміту переходить у pending = true ]
│
▼ (Асинхронне виконання: Network / Server Action)
┌────────┴────────┐
│ │
(Успіх 200 OK) (Збій 500 / Network Error)
│ │
▼ ▼
[ Фіксація стану ] [ Автоматичний Rollback useOptimistic ]
[ isPending = false ] [ Відображення помилки через useActionState ]Хук useOptimistic формує тимчасовий шар поверх стабільного базового стану. Доки асинхронний перехід триває, інтерфейс відображає оптимістичну проекцію. Щойно проміс успішно резолвиться, стабільний стан оновлюється підтвердженими даними, а тимчасовий шар згортається. Якщо операція завершується помилкою (reject), React автоматично скидає оптимістичний шар, повертаючи користувачеві вихідний стан без написання імперативного коду відкату.
На відміну від старих підходів, де розробнику доводилося зберігати знімок стану в try/catch і вручну відновлювати його при збої мережі, useOptimistic прив'язаний до життєвого циклу Transition. Помилка в промісі автоматично анулює транзакцію, повертаючи візуальне дерево до останнього підтвердженого серверного стану.
Користувач активує submit. React збирає значення всіх іменованих полів у нативний об'єкт FormData.
React DOM перемикає useFormStatus (pending = true) для всіх дочірніх компонентів форми без передавання пропсів.
Викликається функція updateOptimistic. Інтерфейс синхронно відображає очікуваний результат до початку мережевого обміну.
Виконується екшен useActionState у фоновому TransitionLane. Дані обробляються клієнтом або надсилаються на сервер.
Підтверджений стан замінює оптимістичний шар. У разі помилки мережі відбувається чистий автоматичний rollback.
Що відбувається під час надсилання форми <form action={myServerAction}>, якщо клієнтський JavaScript ще не завантажився або заблокований розширенням браузера?
Користувач підтверджує введення. Браузер генерує об'єкт FormData з усіх іменованих полів.
Хендлер викликає dispatchOptimistic(). Інтерфейс синхронно відображає новий елемент до надсилання запиту.
Виконується мережева мутація або Server Action. Контекст useFormStatus блокує кнопку сабміту.
При успіху серверні дані стають новим стейтом. При збої оптимістичний шар скасовується без сліду.
Нижче наведено продакшн-компонент, що реалізує useActionState, useOptimistic, useFormStatus та нативний formAction із повною типізацією TypeScript:
import React, { useActionState, useOptimistic } from 'react';
import { useFormStatus } from 'react-dom';
// 1. Визначення контрактів даних
export interface TaskItem {
id: string;
title: string;
priority: 'low' | 'medium' | 'high';
createdAt: number;
}
export interface TaskActionState {
tasks: TaskItem[];
error: string | null;
lastCreatedId: string | null;
}
// 2. Ізольований компонент кнопки з використанням useFormStatus
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="px-4 py-2 bg-accent-cyan text-bg-surface font-semibold rounded-md disabled:opacity-50 flex items-center gap-2"
>
{pending ? (
<>
<span className="animate-spin inline-block w-4 h-4 border-2 border-current border-t-transparent rounded-full" />
Збереження...
</>
) : (
'Створити завдання'
)}
</button>
);
}
// 3. Серверний або клієнтський асинхронний Action
async function taskActionHandler(
prevState: TaskActionState,
formData: FormData
): Promise<TaskActionState> {
const title = formData.get('title') as string | null;
const priority = (formData.get('priority') as TaskItem['priority']) || 'medium';
// Строга валідація вхідних даних
if (!title || title.trim().length < 3) {
return {
...prevState,
error: 'Назва завдання повинна містити щонайменше 3 символи',
};
}
try {
// Імітація мережевої мутації
await new Promise((resolve, reject) => {
setTimeout(() => {
// Симуляція збою для перевірки відкату Optimistic UI
if (title.toLowerCase().includes('помилка')) {
reject(new Error('Мережевий збій під час збереження завдання'));
} else {
resolve(true);
}
}, 1000);
});
const newTask: TaskItem = {
id: crypto.randomUUID(),
title: title.trim(),
priority,
createdAt: Date.now(),
};
return {
tasks: [newTask, ...prevState.tasks],
error: null,
lastCreatedId: newTask.id,
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Невідома помилка сервера';
return {
...prevState,
error: message,
};
}
}
// 4. Головний компонент списку завдань
export function TaskManager({ initialTasks }: { initialTasks: TaskItem[] }) {
const initialState: TaskActionState = {
tasks: initialTasks,
error: null,
lastCreatedId: null,
};
// Хук керування життєвим циклом екшена
const [state, formAction, isPending] = useActionState(taskActionHandler, initialState);
// Хук оптимістичного шару
const [optimisticTasks, addOptimisticTask] = useOptimistic(
state.tasks,
(currentTasks, pendingTitle: string): TaskItem[] => [
{
id: `optimistic-${Date.now()}`,
title: pendingTitle,
priority: 'medium',
createdAt: Date.now(),
},
...currentTasks,
]
);
const handleSubmit = async (formData: FormData) => {
const rawTitle = formData.get('title') as string;
if (rawTitle && rawTitle.trim().length >= 3) {
// Миттєве додавання завдання до оптимістичного інтерфейсу
addOptimisticTask(rawTitle);
}
// Передавання керування стандартному formAction
await formAction(formData);
};
return (
<div className="task-manager p-6 max-w-xl mx-auto space-y-6">
<h2 className="text-2xl font-bold text-text-primary">Керування завданнями (React 19)</h2>
{state.error && (
<div className="p-3 bg-red-900/30 border border-red-500 rounded-md text-red-200 text-sm">
{state.error}
</div>
)}
{/* Форма використовує нативний атрибут action без onSubmit */}
<form action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium mb-1">
Назва завдання
</label>
<input
id="title"
name="title"
type="text"
required
placeholder="Введіть назву..."
className="w-full px-3 py-2 border border-border rounded-md bg-bg-surface"
/>
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium mb-1">
Пріоритет
</label>
<select
id="priority"
name="priority"
className="w-full px-3 py-2 border border-border rounded-md bg-bg-surface"
>
<option value="low">Низький</option>
<option value="medium">Середній</option>
<option value="high">Високий</option>
</select>
</div>
<div className="flex items-center justify-between">
<SubmitButton />
{isPending && (
<span className="text-xs text-text-muted">Синхронізація із сервером...</span>
)}
</div>
</form>
<div className="task-list space-y-2">
<h3 className="text-lg font-semibold">Список завдань</h3>
{optimisticTasks.map(task => {
const isOptimistic = task.id.startsWith('optimistic-');
return (
<div
key={task.id}
className={`p-3 border rounded-md flex justify-between items-center transition-all ${
isOptimistic
? 'opacity-50 border-dashed border-accent-cyan bg-accent-cyan/5'
: 'border-border bg-bg-surface'
}`}
>
<div>
<span className="font-medium">{task.title}</span>
{isOptimistic && (
<span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-accent-cyan/20 text-accent-cyan">
Оптимістично
</span>
)}
</div>
<span className="text-xs text-text-muted">{task.priority}</span>
</div>
);
})}
</div>
</div>
);
}Чому виклик const { pending } = useFormStatus() поверне false, якщо помістити його безпосередньо в компонент, який рендерить сам тег <form>?
Для стандартизації сигнатур асинхронних екшенів у великих кодових базах використовуйте типізовану обгортку з валідацією Zod:
import { z } from 'zod';
export interface ActionResponse<T> {
data: T | null;
errors: Record<string, string[]> | null;
serverError: string | null;
success: boolean;
}
export function createFormAction<TSchema extends z.ZodTypeAny, TResult>(
schema: TSchema,
handler: (data: z.infer<TSchema>) => Promise<TResult>
) {
return async (
prevState: ActionResponse<TResult>,
formData: FormData
): Promise<ActionResponse<TResult>> => {
const rawData = Object.fromEntries(formData.entries());
const validation = schema.safeParse(rawData);
if (!validation.success) {
return {
data: null,
errors: validation.error.flatten().fieldErrors,
serverError: null,
success: false,
};
}
try {
const result = await handler(validation.data);
return {
data: result,
errors: null,
serverError: null,
success: true,
};
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : 'Критичний збій виконання операції';
return {
data: null,
errors: null,
serverError: errorMsg,
success: false,
};
}
};
}Хук useFormStatus читає стан через контекст React DOM. Контекст надається компонентом <form> своїм дочірнім елементам. Якщо викликати useFormStatus у тому самому компоненті, який рендерить <form>, хук завжди повертатиме { pending: false, data: null }.
// ❌ ПОГАНО: хук викликано на одному рівні з <form>, pending завжди false
export function BadForm() {
const { pending } = useFormStatus(); // Не працює!
return (
<form action={myAction}>
<button disabled={pending}>Надіслати</button>
</form>
);
}
// ✅ ПРАВИЛЬНО: винесення кнопки в окремий дочірній підкомпонент
function SubmitBtn() {
const { pending } = useFormStatus(); // Коректно читає контекст предка
return <button disabled={pending}>Надіслати</button>;
}
export function GoodForm() {
return (
<form action={myAction}>
<SubmitBtn />
</form>
);
}Розробники за звичкою додають до форми подію onSubmit і викликають e.preventDefault(). Це повністю блокує нативний конвеєр Actions у React 19: форма не формує FormData, не активує useFormStatus і втрачає підтримку Progressive Enhancement.
// ❌ ПОГАНО: preventDefault скасовує запуск React 19 Action
<form
action={formAction}
onSubmit={(e) => {
e.preventDefault(); // Руйнує життєвий цикл Action!
}}
>Диспетчер, що повертається хуком useOptimistic, може викликатися виключно всередині активного переходу startTransition або всередині функції-екшена, переданої в <form action={...}>. Спроба викликати оптимістичне оновлення у звичайному синхронному обробнику подій (наприклад, у onClick) призведе до помилки або миттєвого зникнення оптимістичного шару.
Оскільки React 19 заохочує неконтрольовані форми, клієнтські обмеження HTML5 (required, type="email") легко обійти через прямі HTTP-запити. Екшен зобов'язаний завжди валідувати formData за допомогою серверних схем (наприклад, Zod), повертаючи структуровані помилки в ActionState.