Overview
React 19 accepts a function as a form’s action. React runs it inside a transition, tracks pending state, and resets uncontrolled fields when it succeeds, so most forms need no useState. With a framework, the function can be a Server Function and the form works before JavaScript loads. Default to uncontrolled inputs read through FormData, return expected errors with useActionState, and validate on the server with one schema. The framework-agnostic baseline (labels, autocomplete, native validation) is forms, and the Next.js mutation rules are nextjs-server-actions.
Default to uncontrolled inputs read through FormData
An uncontrolled input owns its value and React reads it on submit, with no render per keystroke.
function NewNote() {
return (
<form action={createNote}>
<label htmlFor="title">Title</label>
<input id="title" name="title" required />
<button type="submit">Save</button>
</form>
);
}Control an input only when its value drives other UI (live preview, character counter, dependent field) or when validation must run on every keystroke. Controlled inputs cost a render per keystroke; pair an expensive validator with useDeferredValue.
Return expected errors with useActionState
useActionState(action, initialState) returns [state, formAction, isPending]. The action receives (previousState, formData) and returns the next state, so server-side validation errors render without a separate route. Model expected failures as return values and reserve throw for bugs.
"use client";
function NoteForm() {
const [state, formAction, pending] = useActionState(createNote, { errors: {} });
return (
<form action={formAction}>
<input name="title" aria-invalid={!!state.errors.title} />
{state.errors.title && <p role="alert">{state.errors.title}</p>}
<button aria-disabled={pending}>{pending ? "Saving..." : "Save"}</button>
</form>
);
}useFormStatus (from react-dom) reads the enclosing form’s pending state, but only inside a component rendered as a child of the <form>; use it for shared submit buttons. Prefer aria-disabled over disabled while pending so focus stays on the button; see forms.
Show the result early with useOptimistic
useOptimistic(value, reducer?) returns the optimistic value and a setter. Call the setter inside an Action or startTransition; React reverts to the real value when the Action ends, including on failure.
const [optimisticItems, addOptimistic] = useOptimistic(items, (cur, item: Item) => [...cur, { ...item, pending: true }]);
async function action(formData: FormData) {
addOptimistic({ id: crypto.randomUUID(), text: String(formData.get("text")) });
await saveItem(formData);
}Validate on the server with one schema
Define one Zod schema per form and run it in the action; that check is the trust boundary. The same schema can run in the browser for instant feedback, but client checks are UX only.
const NoteSchema = z.object({ title: z.string().min(1).max(200) });
export async function createNote(_prev: unknown, formData: FormData) {
const parsed = NoteSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors };
await db.note.create({ data: parsed.data });
return { errors: {} };
}In Zod 4, z.flattenError(error) replaces the deprecated error.flatten(). Object.fromEntries(formData) keeps only the last value of repeated names; use formData.getAll(name) for multi-select and checkbox groups.
Keep secrets out of controlled state
State lives in the React tree, DevTools, and any logger that captures component state. Passwords, tokens, card numbers, and one-time codes belong in uncontrolled inputs read once from FormData and never echoed back. See react-error-boundaries for scrubbing reporter payloads.
<input name="password" type="password" autoComplete="new-password" />