Overview

React form patterns in 2026 are simpler than the controlled-input drills of the hooks era. Default to uncontrolled inputs and server actions; lift to controlled only when interactive validation requires it. The native <form> element is a complete submission primitive, and React 19 wired it into the server-action contract. This page covers the rules that keep forms accessible, typesafe, and resilient. For the framework-agnostic baseline, see forms.

Default to uncontrolled inputs

An uncontrolled input owns its own value; React reads it on submit via FormData. No useState, no re-render per keystroke, no controlled-input boilerplate.

function NewNote() {
  return (
    <form action={createNote}>
      <label htmlFor="title">Title</label>
      <input id="title" name="title" required />
      <button type="submit">Save</button>
    </form>
  );
}

Each input renders once. The browser tracks the value; React reads it on submit. Reach for controlled inputs only when the value drives other UI (a live preview, a character counter, a dependent field), or when validation must run on every keystroke.

Use server actions for mutations

A server action is a function marked "use server" that the React 19 framework wires up as the form’s submit handler. The form posts directly to it without an ad-hoc API route.

// app/notes/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
 
const Schema = z.object({ title: z.string().min(1).max(200) });
 
export async function createNote(formData: FormData) {
  const parsed = Schema.parse(Object.fromEntries(formData));
  await db.note.create({ data: parsed });
  revalidatePath("/notes");
}

The form works without JavaScript: the browser submits, the server runs the action, the page re-renders. With JavaScript, the framework intercepts and avoids the full-page navigation. Same code, two execution paths. See react-server-components for the surrounding model and nextjs for the App Router specifics.

Read fields with FormData, not hand-rolled JSON

FormData captures every named field at once: files, multi-select values, grouped inputs, checkboxes. Pass it directly to the server action; convert to a plain object with Object.fromEntries(formData) when the validator expects one.

const data = Object.fromEntries(formData);
const parsed = Schema.parse(data);

Hand-rolling JSON.stringify({ title: state.title }) drifts from the form every time a field is added or renamed. FormData stays in sync because the names are the source of truth.

Validate with Zod on the server, mirror on the client

Define one Zod schema per form; export it from the action module. Run it on the server inside the action; the same schema can run on the client for instant feedback.

export const NoteSchema = z.object({
  title: z.string().min(1, "Title is required").max(200),
  body: z.string().max(10_000).optional(),
});

The server validation is the trust boundary. The client validation is UX, not a gate. Never trust a client-only check; an attacker, a broken script, or a stale page bypasses it. See forms for the wider rule.

Lift to controlled inputs when validation is interactive

When a field needs validation on every keystroke (password strength meter, slug availability check, character counter, dependent field that disables a button), control the input.

"use client";
function PasswordField() {
  const [pw, setPw] = useState("");
  const strength = scoreStrength(pw);
  return (
    <>
      <input
        name="password"
        type="password"
        value={pw}
        onChange={(e) => setPw(e.target.value)}
        autoComplete="new-password"
        minLength={12}
      />
      <Meter score={strength} />
    </>
  );
}

Controlled inputs cost a render per keystroke. Use useDeferredValue from react-suspense when the validator is expensive.

Show pending and error state with useFormStatus and useActionState

useFormStatus reads the pending state of the enclosing form; use it inside the submit button. useActionState wraps a server action and returns the last result, so the form can render server-side validation errors without a separate route.

"use client";
import { useFormStatus } from "react-dom";
import { useActionState } from "react";
 
function Submit() {
  const { pending } = useFormStatus();
  return <button type="submit" disabled={pending}>{pending ? "Saving..." : "Save"}</button>;
}
 
function NoteForm() {
  const [state, action] = useActionState(createNote, { errors: {} });
  return (
    <form action={action}>
      <input name="title" />
      {state.errors.title && <p role="alert">{state.errors.title}</p>}
      <Submit />
    </form>
  );
}

The button stays focusable while pending so assistive tech reports the wait. See forms for why disabling submit on click is wrong.

Never store secrets in form state

A controlled-form useState lives in the React tree, the DevTools, and any error logger that captures component state. Passwords, tokens, credit card numbers, and one-time codes belong in uncontrolled inputs, read once via FormData, sent to the server, and never echoed back.

// Wrong: password lives in state and may surface in Sentry breadcrumbs.
const [password, setPassword] = useState("");
 
// Right: uncontrolled, read on submit, discarded.
<input name="password" type="password" autoComplete="new-password" />

For redaction at the logger level, see react-error-boundaries and the Sentry configuration there. For typed end-to-end payloads, see typescript.