React 19’s form actions replace the familiar pile of isSubmitting, error and result state with one hook, but teams porting existing forms hit the same three surprises: the form resets its uncontrolled inputs after every action, errors returned from the action need a shape the UI can map to fields, and client-side validation seems to have nowhere to go.
This page covers useActionState as a submission primitive inside the React form hook architecture. It works with plain client actions and with server functions in frameworks that support them; the patterns for server-rendered frameworks specifically are in returning validation errors from Next.js server actions.
Context and prerequisites
The pieces:
<form action={fn}>— React callsfn(formData)on submit, inside a transition. NopreventDefault, noonSubmit.useActionState(action, initialState)— returns[state, formAction, isPending]. React callsaction(previousState, formData), stores whatever it returns as the nextstate, and setsisPendingwhile it runs. Submissions are queued and run in order.useFormStatus()— fromreact-dom, callable in any component inside the form, returns{ pending, data, method, action }for the enclosing form. Ideal for a reusable submit button.- Automatic reset — after a successful action, React resets uncontrolled fields of a
<form action>to theirdefaultValues. Great for a comment box; surprising for an edit form that failed validation.
The key design decision is the state shape the action returns. It is your error model, your “last submitted values” store and your success signal all at once.
The core pattern: a typed action state with values and errors
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { z } from "zod";
const Profile = z.object({
displayName: z.string().trim().min(2, "Use at least 2 characters."),
email: z.string().trim().email("Enter an email like [email protected]."),
});
type Values = { displayName: string; email: string };
type ActionState =
| { status: "idle"; values: Values }
| { status: "invalid"; values: Values; fieldErrors: Partial<Record<keyof Values, string>>; formError?: string }
| { status: "saved"; values: Values; savedAt: number };
async function saveProfile(_prev: ActionState, formData: FormData): Promise<ActionState> {
// Read raw strings; keep them to re-populate the form on failure.
const values: Values = {
displayName: String(formData.get("displayName") ?? ""),
email: String(formData.get("email") ?? ""),
};
const parsed = Profile.safeParse(values);
if (!parsed.success) {
const fieldErrors: Partial<Record<keyof Values, string>> = {};
for (const issue of parsed.error.issues) {
const key = issue.path[0] as keyof Values;
fieldErrors[key] ??= issue.message; // first message per field
}
return { status: "invalid", values, fieldErrors };
}
const res = await fetch("/api/profile", { method: "PUT", body: JSON.stringify(parsed.data) });
if (!res.ok) return { status: "invalid", values, fieldErrors: {}, formError: "We couldn't save your profile. Try again." };
return { status: "saved", values: parsed.data, savedAt: Date.now() };
}
function SubmitButton() {
const { pending } = useFormStatus(); // reads the ENCLOSING form's status
return <button type="submit" aria-disabled={pending}>{pending ? "Saving…" : "Save profile"}</button>;
}
export function ProfileForm({ initial }: { initial: Values }) {
const [state, formAction] = useActionState(saveProfile, { status: "idle", values: initial });
const err = state.status === "invalid" ? state.fieldErrors : {};
return (
// key: remount after each result so defaultValue reflects state.values —
// this is what counteracts the automatic reset on failure.
<form action={formAction} key={state.status === "saved" ? state.savedAt : "edit"} noValidate>
{state.status === "invalid" && state.formError && <p role="alert">{state.formError}</p>}
<label htmlFor="displayName">Display name</label>
<input id="displayName" name="displayName" defaultValue={state.values.displayName}
aria-invalid={err.displayName ? true : undefined} aria-describedby={err.displayName ? "displayName-error" : undefined} />
{err.displayName && <p id="displayName-error">{err.displayName}</p>}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" defaultValue={state.values.email}
aria-invalid={err.email ? true : undefined} aria-describedby={err.email ? "email-error" : undefined} />
{err.email && <p id="email-error">{err.email}</p>}
<SubmitButton />
<p role="status">{state.status === "saved" ? "Profile saved." : ""}</p>
</form>
);
}
Step-by-step walkthrough
- Design the returned state as a discriminated union.
idle,invalidandsavedeach carry what their UI needs; TypeScript then forces every render path to handle all three. - Always return the submitted values. React resets uncontrolled inputs after the action completes; feeding
state.valuesback asdefaultValuerestores what the user typed when validation fails. - Validate inside the action with a schema. The same Zod schema can run on the server when the action is a server function — see sharing one Zod schema between client and server.
- Map issues to one message per field. Keep the first issue per path; show form-level failures (network, 5xx) in a separate
role="alert"paragraph, following modelling form-level vs field-level errors. - Put the pending UI in a child using
useFormStatus. It reads the nearest parent form, so oneSubmitButtoncomponent works in every form without prop threading. - Keep instant client checks where they help. Per-field feedback on blur still uses your field hook; the action is the authoritative check at submit time.
Failure modes and edge cases
1. Inputs wiped after a failed submit
The automatic reset applies to uncontrolled fields of a form whose action is a function. Returning values and using them as defaultValue fixes the content; keying the form so it remounts with those defaults makes it deterministic.
2. Focus lost after the action returns
A remount (from the key) moves focus to <body>. After an invalid result, move focus to the error summary or the first invalid field in an effect keyed on the state, following moving focus to the first invalid field.
3. useFormStatus returns pending: false
It only reports the form that contains the component calling it. Calling it in the same component that renders the <form> always returns the default. Move the button into a child component.
4. Controlled inputs and actions
Controlled inputs are not reset by React, and their values are still read from the DOM into FormData. Mixing them is fine, but be consistent: if the field is controlled, its value comes from your state, not from state.values.
5. Queued submissions
Pressing submit three times queues three action calls, each receiving the previous one’s state. That preserves order, but still sends three requests. Guard with isPending in the button and an idempotency key server-side, as in handling double submit and idempotency.
Verification checklist
Frequently Asked Questions
Do I still need a form library with React 19 actions?
For simple forms, actions plus a schema are enough. Libraries still earn their place for field arrays, per-field subscriptions in large forms, and rich client-side validation timing. Many now integrate with actions, so the choice is about client-side ergonomics rather than submission.
Can I opt out of the automatic reset?
Not with a flag. You either return the values and use them as defaults (as above), use controlled inputs, or call event.preventDefault() in an onSubmit and invoke the action yourself with startTransition. Returning values is the least code and also works with progressive enhancement.
Does useActionState work without JavaScript?
With server functions in a framework that supports them, the form posts to the server and renders the returned state in the response, so it works before hydration. With a client-only action, nothing happens without JavaScript; provide a real action URL fallback if that matters, as in progressive enhancement for server-rendered forms.