Управляемые поля, FormData, проверка внешних данных, действия, useActionState, useFormStatus и оптимистичные изменения.
Форма — цельный сценарий пользовательского ввода, а не обязательный набор функций обновления на каждую клавишу.
Вы построите доступную форму с типизированной проверкой данных, состояниями выполнения, ошибки и успеха, защитой от повторной отправки и предварительным отображением результата.
Понадобятся обработчики, моделирование состояния, unknown и размеченные объединения. Итог урока — форма создания заявки с проверкой в браузере и на сервере, которая не теряет данные после отказа.
<form> объединяет поля, отправку по Enter и встроенную проверку браузера. Логика отправки должна жить в onSubmit или action, а не только в щелчке по кнопке: иначе клавиатурная отправка обойдёт код. Каждому полю нужны подпись и осмысленный name, потому что он определяет ключ в FormData.
Форма из div теряет отправку по Enter, встроенную проверку и семантику для вспомогательных технологий:
// ❌ Enter в поле ничего не делает, браузер не знает, что это форма
<div>
<input onChange={handleChange} />
<button onClick={handleSubmit}>Создать</button>
</div>
// ✅ нативная форма: Enter, submit, автозаполнение и подписи работают
<form action={createTicket}>
<label htmlFor="title">Заголовок</label>
<input id="title" name="title" required minLength={3} />
<button type="submit">Создать</button>
</form>name здесь не оформление, а ключ данных: именно по нему значение попадёт в FormData. Поле без name не будет отправлено вообще — ни нативно, ни через действие.
Проверьте себя. Отправьте форму клавишей Enter из текстового поля и активируйте кнопку с клавиатуры.
Частая ошибка. Собрать форму из div и обработчика щелчка без нативной семантики.
Управляемое (controlled) поле получает value или checked и синхронный onChange; источником истины служит состояние React. Неуправляемое (uncontrolled) поле использует defaultValue и хранит значение в DOM до чтения формы. Нельзя переключать одно поле между режимами: значение управляемого поля не должно становиться undefined. Выбор зависит от того, нужен ли приложению каждый промежуточный символ.
Каждый режим уместен в своём случае:
// управляемое: нужен каждый символ для живого предпросмотра
const [query, setQuery] = useState('');
<input name="query" value={query} onChange={(event) => setQuery(event.target.value)} />
// неуправляемое: значение нужно только при отправке
<textarea name="comment" defaultValue={ticket.comment} />Переключение между режимами происходит незаметно — через undefined:
// ❌ пока maybeString === undefined, поле неуправляемое; потом станет управляемым
<input value={maybeString} onChange={handleChange} />
// ✅ значение всегда строка
<input value={maybeString ?? ''} onChange={handleChange} />React выдаёт предупреждение о таком переключении, но последствия хуже предупреждения: введённый пользователем текст может исчезнуть в момент, когда значение впервые станет определённым.
Проверьте себя. Сделайте управляемый поиск с живым предпросмотром и неуправляемое поле комментария, читаемое при отправке.
Частая ошибка. Передать value={maybeString} и незаметно переключить поле между управляемым и неуправляемым режимами.
FormData.get возвращает строку, File или null. Числа, даты и логические значения автоматически не восстанавливаются. В браузере и на сервере вход нужно разобрать и проверить; тип функции в TypeScript не защищает от вручную сформированного HTTP-запроса. Ошибки данных возвращайте как ожидаемый результат, а системные сбои обрабатывайте отдельно.
const rawTitle = formData.get('title');
if (typeof rawTitle !== 'string' || rawTitle.trim().length < 3) {
return { status: 'invalid' as const, fields: { title: 'Минимум 3 символа' } };
}Проверка одновременно исключает null/File и применяет доменное правило. Сервер повторяет доверенную валидацию независимо от клиента.
Утверждение типа выглядит короче и не проверяет ничего:
// ❌ при отсутствующем поле получим null, при загрузке файла — File
const title = formData.get('title') as string;
const count = Number(formData.get('count')); // NaN не отличить от нуляКомпилятор здесь бессилен: as string — обещание разработчика, а не проверка. Запрос можно сформировать вручную, без вашей формы, и прислать что угодно. Поэтому граница проверяется во время выполнения — и на клиенте для быстрой обратной связи, и на сервере как единственная доверенная проверка.
Проверьте себя. Отправьте отсутствующее поле, File вместо строки и пробельное название.
Частая ошибка. Писать formData.get("title") as string без проверки.
React 19 позволяет передать функцию в action формы. Асинхронное действие управляет состоянием отправки; после успеха неуправляемая форма очищается автоматически. Функция может работать в браузере или на сервере, если это поддерживает фреймворк. Авторизация, идемпотентность и обработка системных ошибок всё равно обязательны.
Действие возвращает результат для интерфейса и не выбрасывает ожидаемые ошибки:
// src/features/tickets/actions.ts
export async function createTicket(_prev: FormState, formData: FormData): Promise<FormState> {
const parsed = parseTicketForm(formData);
if (!parsed.ok) return { status: 'invalid', fields: parsed.fields }; // ожидаемый исход
try {
const created = await api.createTicket(parsed.value);
return { status: 'success', id: created.id };
} catch (error) {
reportError(error); // системный сбой: в журнал детали, пользователю — общее сообщение
return { status: 'failure', message: 'Не удалось создать заявку. Попробуйте ещё раз.' };
}
}Три исхода разделены намеренно: ошибка ввода показывается у поля, системный сбой — с предложением повтора и сохранённым вводом. И само имя action ничего не гарантирует: клиентская функция остаётся клиентской, серверную создаёт директива use server и протокол фреймворка.
Проверьте себя. Реализуйте действие с успешным результатом, ошибкой данных и сетевым отказом.
Частая ошибка. Считать любую функцию с именем action доверенной серверной операцией.
useActionState и типизированный результатuseActionState связывает действие с последним результатом и признаком выполнения. Функция получает первым аргументом предыдущее состояние, а затем входные данные. Результат удобно моделировать объединением idle | invalid | success | failure. Не передавайте пользователю объект исключения или внутренние детали сервера.
type FormState =
| { status: 'idle' }
| { status: 'invalid'; fields: Record<string, string> }
| { status: 'success'; id: string };
const [state, submitAction, pending] = useActionState(saveRequest, { status: 'idle' });Объединение заставляет JSX явно разобрать каждый исход, а pending описывает текущее выполнение отдельно от последнего результата.
Ошибка поля связывается с полем — иначе её не прочитает программа чтения с экрана:
<form action={submitAction}>
<label htmlFor="title">Заголовок</label>
<input
id="title"
name="title"
defaultValue={lastTitle} // ввод сохранён после отказа
aria-invalid={state.status === 'invalid' && !!state.fields.title}
aria-describedby={state.status === 'invalid' && state.fields.title ? 'title-error' : undefined}
/>
{state.status === 'invalid' && state.fields.title && (
<p id="title-error">{state.fields.title}</p>
)}
</form>Тип string | null на все случаи сразу здесь не работает: по нему нельзя отличить «ещё не отправляли» от «успех без сообщения» и от «ошибка».
Проверьте себя. Свяжите ошибку поля с aria-describedby и сохраните пользовательский ввод после неудачной проверки.
Частая ошибка. Использовать string | null одновременно для успеха, ошибки и отсутствия результата.
useFormStatus у потомка формыuseFormStatus читает состояние родительской формы подобно контексту. Хук должен вызываться в компоненте внутри <form>; компонент, который только возвращает форму, не видит состояние ещё не созданного родителя. Результат содержит pending и данные текущей отправки, но постоянную проверку каждого символа не стоит строить на чтении FormData.
// src/SubmitButton.tsx
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Сохраняем…' : 'Сохранить'}</button>;
}Кнопка находится внутри <form action={...}>, поэтому получает состояние ближайшей формы без передачи свойства через промежуточные компоненты.
Типичная ошибка — вызвать хук в том же компоненте, который отрисовывает форму:
// ❌ pending всегда false: форма ещё не является родителем этого компонента
function TicketForm() {
const { pending } = useFormStatus();
return <form action={createTicket}><button disabled={pending}>Создать</button></form>;
}Хук читает контекст ближайшей формы выше по дереву. В момент вызова такой формы над компонентом нет, поэтому статус всегда пустой — нужен отдельный компонент внутри формы.
Проверьте себя. Добавьте две формы и убедитесь, что pending одной не блокирует кнопку другой.
Частая ошибка. Вызвать useFormStatus рядом с JSX form и ожидать статус этой же формы.
useOptimistic позволяет заранее показать предполагаемый результат действия. Функция преобразования должна быть чистой. Каждой предварительной записи нужны стабильный клиентский идентификатор и статус: тогда подтверждение можно связать с серверным идентификатором, а отказ — откатить или повторить. Не показывайте необратимый успех до ответа сервера.
Клиентский идентификатор — единственный надёжный способ связать предварительную запись с ответом:
// src/features/comments/CommentList.tsx
const [optimistic, addOptimistic] = useOptimistic(
comments,
(current: Comment[], draft: PendingComment) => [...current, { ...draft, status: 'pending' }],
);
async function handleSubmit(formData: FormData) {
const clientId = crypto.randomUUID(); // стабильная связь запроса и записи
addOptimistic({ clientId, body: String(formData.get('body') ?? '') });
await sendComment({ clientId, body: formData.get('body') });
}Сопоставление по индексу или тексту ломается при параллельных отправках: два ответа могут прийти в обратном порядке, а два одинаковых комментария неразличимы. И необратимые исходы — «платёж выполнен», «письмо отправлено» — не показывают предварительно: откатить такое сообщение нельзя.
Проверьте себя. Смоделируйте два параллельных предварительных добавления и ответы сервера в обратном порядке.
Частая ошибка. Сопоставлять подтверждение по индексу списка или тексту записи.
Отключённая кнопка уменьшает число случайных повторов, но не заменяет идемпотентность сервера: запрос может повторить сеть, другая вкладка или сторонний клиент. Для создания записи нужен ключ идемпотентности (idempotency key) или предметная дедупликация. Ошибку данных показывайте рядом с полем; при системном отказе сохраняйте ввод и предлагайте безопасный повтор.
Ключ генерируется один раз на попытку и передаётся заголовком:
// src/features/tickets/api.ts
export async function createTicket(input: TicketInput, idempotencyKey: string) {
return fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
body: JSON.stringify(input),
});
}Сервер по этому ключу распознаёт повтор и возвращает результат первой операции вместо создания второй записи. Именно поэтому ключ нельзя генерировать заново при повторной отправке — иначе для сервера это новая операция.
disabled={pending} защищает только от двойного клика в одной вкладке. Повторную доставку запроса сетью, вторую вкладку и восстановление после разрыва соединения он не покрывает вообще.
Проверьте себя. Дважды отправьте одну команду с тем же ключом идемпотентности и убедитесь, что сервер создал одну запись.
Частая ошибка. Считать disabled={pending} гарантией единственного выполнения.
Реализуйте форму заявки через действие React 19. Прочитайте FormData как unknown, проверьте поля, верните их ошибки через useActionState, покажите выполнение через useFormStatus, добавьте предварительную запись с временным идентификатором и откат при отказе.
Работа готова, когда форма работает с клавиатуры, повторная отправка контролируется, ошибки связаны с полями, а предварительное состояние согласуется с подтверждённым результатом.
Далее: Проектирование API компонентов