The default React form, the one in most tutorials:
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [status, setStatus] = useState<"idle" | "sending">("idle");
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input value={password} onChange={(e) => setPassword(e.target.value)} />
// ~14 controlled setters, one reset function, a status enum.
// every keystroke re-renders the whole form. why?Two fields, fourteen lines of plumbing, and a re-render on every keystroke, all to read two strings once at submit time. React 19 actions let the form be a form.
Step 1: Drop controlled state for read-once fields
Controlled inputs exist to re-render on every keystroke. If the form only needs the values at submit time, that is pure cost:
// the whole "state layer", replaced by HTML attributes
<input name="email" type="email" required />
<input name="password" type="password" required minLength={8} />Give each input a name, skip value and onChange, and let the browser own the field. The rule of thumb: if nothing on screen changes while the user types, the field does not need to be controlled.
Step 2: Pass an action to the form
The form's action prop receives FormData automatically:
async function signup(formData: FormData) {
"use server";
const email = String(formData.get("email"));
const password = String(formData.get("password"));
await createUser(email, password);
}
export default function SignupForm() {
return (
<form action={signup}>
<input name="email" type="email" required />
<input name="password" type="password" required minLength={8} />
<button>Sign up</button>
</form>
);
}No onSubmit, no preventDefault, no new FormData(e.currentTarget) ceremony. On a client component the action is a plain function receiving the same FormData; on a server component it is a server action and the browser posts straight to it.
Step 3: Wire pending and feedback with hooks
useActionState wraps the action with its result and a pending flag:
const [errors, submit, isPending] = useActionState(
async (prev, formData) => {
const email = String(formData.get("email"));
if (!email.includes("@")) {
return { email: "Enter a valid email" };
}
await createUser(email);
return null;
},
null,
);
<form action={submit}>{/* ... */}</form>
{errors?.email && <p>{errors.email}</p>}useFormStatus lets a submit button know its parent form is submitting without prop drilling:
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending…" : "Sign up"}</button>;
}Both replace the useState juggling the controlled version needed: the status enum, the loading flag, the error object. The hooks know.
Step 4: Validate with the DOM, then the server
required, type="email", minLength: the browser validates FormData forms natively before the action ever runs:
<input name="email" type="email" required />
// browser blocks submission and shows its own message.
// zero validation code in React
// server still validates. the client is never trusted:
const parsed = schema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors };You stop rewriting email regex in React for the third time, and the server check catches everything the browser missed, including submissions that never touched your form.
Step 5: Reset with a ref or a key, not with state
An uncontrolled form resets without touching a single setter:
const formRef = useRef<HTMLFormElement>(null);
const [result, submit] = useActionState(async (prev, formData) => {
await sendMessage(formData);
formRef.current?.reset(); // clears every field, one line
return { ok: true };
}, null);
<form ref={formRef} action={submit}>{/* fields with names, nothing else */}</form>No per-field setters to call, because there are no per-field setters. This is where the pattern pays for itself: adding a field adds one input line, not a state variable, a setter, a reset call, and a re-render.
The whole pattern: fields with name attributes, an action that reads FormData, useActionState for results and pending. The form you ship in twenty lines is the same form the controlled version ships in eighty.
Building forms in React that fight you on every field? Tell me about the form and I will tell you which fields can drop their state today.