Form actions и useActionState
Form actions в React 19 при сабмите запускают функцию с FormData; useActionState бесплатно даёт pending, result и error, а useFormStatus читает состояние сабмита внутри потомка — хватит вручную городить onSubmit + isSubmitting, и валидируй action на сервере.
В каждом React-приложении есть одна и та же написанная вручную форма: preventDefault, useState на каждое поле или один на весь объект, булев флаг isSubmitting, который ты ставишь в true перед запросом и в false в finally, состояние error и try/catch, который должен не забыть всё это сбросить. Оно работает, и это одни и те же сорок строк на каждой форме, и finally хотя бы в одном месте неправильный, потому что кто-то забыл сбросить isSubmitting в ветке с ошибкой валидации.
React 19 вбирает эту церемонию в платформу. <form action={fn}> запускает твою функцию с отправленным FormData; useActionState сам отслеживает флаг pending и возвращённый результат; useFormStatus позволяет вложенной кнопке сабмита узнать, что она в pending, без единого проброшенного пропса. Сдвиг уровня senior не в «новых хуках» — а в том, что один и тот же action работает, сабмитишь ли ты на клиенте или постишь на сервер, поэтому машинерия pending/error, которую ты раньше пересобирал под каждую форму, теперь один механизм.
После этого урока ты можешь собрать <form action={fn}>, получающую FormData, управлять её жизненным циклом pending/result/error через useActionState вместо самописного состояния isSubmitting, читать pending-статус сабмита внутри дочерней кнопки через useFormStatus и сформулировать два режима отказа: переизобретение onSubmit + isSubmitting, когда actions уже дают это, и забывание, что action — это публичный эндпойнт, который обязан валидировать свой ввод на сервере.
Form action — это функция, которая при сабмите запускается с FormData; <form action={fn}> заменяет onSubmit + preventDefault + чтение каждого поля. React вызывает твою функцию с FormData формы, поэтому ты читаешь поля по name, а не подключаешь контролируемое состояние к каждому инпуту. Никакого события предотвращать не нужно; сабмит — это и есть вызов action.
function ContactForm() {
async function submit(formData: FormData) {
const email = formData.get("email") as string;
await sendMessage(email, formData.get("body") as string);
}
return (
<form action={submit}>
<input name="email" type="email" />
<textarea name="body" />
<button>Send</button>
</form>
);
}Инпуты неконтролируемые — контракт это name. К useState на поле ты тянешься, только когда тебе действительно нужно рендерить по нажатиям клавиш (живая валидация, счётчик символов), а не просто собрать значение на сабмите.
useActionState оборачивает action и возвращает [state, dispatch, isPending] — это твой result, error и флаг pending, и ни один из них не написан вручную. Ты даёшь ему функцию в форме редьюсера (prevState, formData) => nextState и начальное состояние. React запускает её на сабмите, отслеживает переход в pending и сохраняет то, что ты вернул, как новый state. Ошибки валидации — это просто возвращённое значение, а не выброшенное исключение, которое нужно ловить и прятать.
type State = { ok: boolean; error?: string };
async function submitContact(_prev: State, formData: FormData): Promise<State> {
const email = String(formData.get("email") ?? "");
if (!email.includes("@")) return { ok: false, error: "Enter a valid email" };
await sendMessage(email, String(formData.get("body") ?? ""));
return { ok: true };
}
function ContactForm() {
const [state, action, isPending] = useActionState(submitContact, { ok: false });
return (
<form action={action}>
<input name="email" type="email" />
<textarea name="body" />
{state.error && <p role="alert">{state.error}</p>}
<button disabled={isPending}>{isPending ? "Sending…" : "Send"}</button>
</form>
);
}Заметь, что исчезло: нет const [isSubmitting, setIsSubmitting], нет try/catch, нет finally для сброса флагов. Флаг pending приходит из перехода, которым React и так управляет; error — это просто состояние, которое ты вернул.
useFormStatus читает состояние сабмита окружающей формы изнутри потомка — так общий <SubmitButton> узнаёт, что он в pending, без единого пропса. Подвох, на котором спотыкаются: его нужно вызывать в компоненте, отрендеренном внутри <form>, а не в компоненте, который рендерит форму. Эта граница — весь смысл: она даёт тебе отгрузить переиспользуемую кнопку сабмита, которую любая форма может вставить, и она загорается автоматически.
import { useFormStatus } from "react-dom";
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus(); // reads the parent <form>, not props
return <button disabled={pending}>{pending ? "Submitting…" : children}</button>;
}
// any form gets the pending button for free
<form action={action}>
<input name="email" />
<SubmitButton>Save</SubmitButton>
</form>useFormStatus — для локальной обратной связи кнопки; isPending от useActionState — для логики владельца формы (отключить fieldset, показать результат). Используй то, что сидит там, где тебе нужен сигнал, — не пробрасывай isPending вниз, когда useFormStatus читает его нативно.
Один и тот же action работает на клиенте или как server action — но server action это публичный HTTP-эндпойнт, поэтому он обязан валидировать свой ввод. Пометь функцию "use server", и <form action={serverAction}> постит FormData на сервер; форма работает ещё до гидрации, и прогрессивное улучшение настоящее. Ловушка уровня senior: клиентская валидация (type="email", отключённая кнопка) — это UX, а не граница безопасности. Кто угодно может запостить произвольный FormData прямо в этот action, поэтому он обязан перевалидировать и авторизовать на сервере, как будто клиент вообще не выполнялся.
// app/actions.ts
"use server";
import { z } from "zod";
const Contact = z.object({ email: z.string().email(), body: z.string().min(1).max(2000) });
export async function submitContact(_prev: State, formData: FormData): Promise<State> {
const parsed = Contact.safeParse({ email: formData.get("email"), body: formData.get("body") });
if (!parsed.success) return { ok: false, error: "Invalid input" }; // server is the real gate
await requireSession(); // authz here, never trust the client
await db.messages.insert(parsed.data);
return { ok: true };
}Сигнатура action идентична клиентской версии — в этом и унификация. Меняется то, что граница переехала через сеть, и всё по ту сторону относится к своему вводу как к враждебному.
Та же форма рассылки, до и после actions — смотри, как сорок строк состояния схлопываются. «До» — это каноническая самописная форма: контролируемое значение, булев isSubmitting, строка error и try/catch/finally, жонглирующий всеми тремя.
function Newsletter() {
const [email, setEmail] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email.includes("@")) { setError("Invalid email"); return; }
setIsSubmitting(true); setError(null);
try {
await subscribe(email);
setDone(true);
} catch {
setError("Something went wrong");
} finally {
setIsSubmitting(false); // easy to forget on the early-return branch above
}
}
return (
<form onSubmit={onSubmit}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{error && <p>{error}</p>}
<button disabled={isSubmitting}>{isSubmitting ? "…" : "Subscribe"}</button>
</form>
);
}Четыре useState, ручной preventDefault и finally, который — латентный магнит для багов. «После» отдаёт жизненный цикл во владение action: ошибка валидации это возвращённое значение, pending приходит из useActionState, а кнопка читает свой собственный статус.
type State = { done: boolean; error?: string };
async function subscribeAction(_prev: State, fd: FormData): Promise<State> {
const email = String(fd.get("email") ?? "");
if (!email.includes("@")) return { done: false, error: "Invalid email" };
try { await subscribe(email); } catch { return { done: false, error: "Something went wrong" }; }
return { done: true };
}
function Newsletter() {
const [state, action] = useActionState(subscribeAction, { done: false });
if (state.done) return <p>Subscribed ✓</p>;
return (
<form action={action}>
<input name="email" type="email" />
{state.error && <p role="alert">{state.error}</p>}
<SubmitButton>Subscribe</SubmitButton>
</form>
);
}Число кусков состояния падает с четырёх до одного возвращённого объекта; isSubmitting и его сброс исчезают совсем; путь ошибки с ранним возвратом не может оставить зависший спиннер, потому что нет флага, который мог бы зависнуть. Паттерн не добавляет мощи, которую ты не получил бы руками, — он удаляет бухгалтерию, которая была неправильной на каждой третьей форме.
▸Частая ошибка
Главная ошибка при переходе на формы React 19 — оставить старую машинерию и добавить новую: <form action={action}>, которая всё ещё подключает onSubmit, или компонент, который вызывает useActionState, но при этом держит isSubmitting через useState, которое сам выставляет. Теперь две системы отслеживают одно и то же состояние pending, и они расходятся — кнопка отключается по одному флагу, а fieldset читает другой. Если ты на actions — удали самописный isSubmitting, preventDefault и finally; пусть isPending (или useFormStatus) будет единственным источником. Смешивать их строго хуже, чем любой по отдельности.
▸Граничные случаи
Server action достижим для всякого, кто может сформировать HTTP POST, — он не защищён клиентскими required, type="email" или отключённой кнопкой формы. Поэтому функции с "use server" несут те же обязательства, что и любой API-роут: валидировать форму данных (парси, не доверяй, что formData.get вернёт строку — это может быть File или null), авторизовать вызывающего (requireSession) и ограничивать частоту, если они пишут. Клиентская валидация всё ещё заслуживает места как мгновенный UX, но это слой удобства над серверной границей, никогда не замена ей. Относись к каждому action как к публичному эндпойнту, потому что он им и является.
Коллега отгружает форму React 19 с <form action={action}>, управляемую useActionState, но при этом держит отдельный const [isSubmitting, setIsSubmitting] = useState(false), который выставляет внутри action, плюс onSubmit, вызывающий preventDefault. В чём senior-исправление?
React 19 превращает сабмит формы в механизм платформы вместо сорока строк самописного состояния. <form action={fn}> запускает твою функцию с отправленным FormData — без preventDefault, без контролируемого состояния на поле, чтение по name. useActionState(fn, initial) возвращает [state, dispatch, isPending]: твой result и ошибки валидации — это просто возвращённое значение, а флаг pending приходит из перехода, которым React уже управляет, — так булев isSubmitting, try/catch и багоопасный сброс в finally исчезают. useFormStatus читает это состояние pending изнутри потомка, позволяя переиспользуемой кнопке сабмита загораться без проброшенных пропсов. Один и тот же action работает на клиенте или как server action ("use server") — в этом унификация, — но server action это публичный эндпойнт, поэтому он обязан перевалидировать и авторизовать свой ввод, как будто клиент вообще не выполнялся. Два режима отказа, которые надо называть вслух: не переизобретай onSubmit + isSubmitting, когда action уже даёт и то и другое, и никогда не позволяй клиентской валидации подменять серверную. Подбирай сигнал под место, где он нужен, — isPending для владельца формы, useFormStatus для кнопки, — и отдай жизненный цикл во владение action.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.