В ранних версиях 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 со строгой типизацией:
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>?
Для стандартизации сигнатур асинхронных экшенов в крупной кодовой базе разработаем типизированный утилитный враппер:
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 не может сопоставить оптимистичный слой с жизненным циклом операции.
Поскольку React 19 поощряет неконтролируемые формы, клиентская HTML5-валидация (required, type="email") легко обходится через прямые HTTP-запросы или инспекцию DOM. Экшен обязан всегда валидировать formData с помощью серверных схем (например, Zod), возвращая ошибки в ActionState.