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 calls fn(formData) on submit, inside a transition. No preventDefault, no onSubmit.
  • useActionState(action, initialState) — returns [state, formAction, isPending]. React calls action(previousState, formData), stores whatever it returns as the next state, and sets isPending while it runs. Submissions are queued and run in order.
  • useFormStatus() — from react-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 their defaultValues. 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.

One submission through useActionState The user presses submit. React starts a transition, sets isPending to true and calls the action with the previous state and the form data. The action validates the data with a schema; it fails, so the action returns a state containing field errors and the submitted values. React stores it as the new state and sets isPending to false. The component renders each field's error and uses the returned values as default values, so the user's input is not lost to the automatic form reset. User React Action submit action(prevState, formData); isPending = true { ok: false, errors, values } render errors; defaults from values

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

  1. Design the returned state as a discriminated union. idle, invalid and saved each carry what their UI needs; TypeScript then forces every render path to handle all three.
  2. Always return the submitted values. React resets uncontrolled inputs after the action completes; feeding state.values back as defaultValue restores what the user typed when validation fails.
  3. 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.
  4. 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.
  5. Put the pending UI in a child using useFormStatus. It reads the nearest parent form, so one SubmitButton component works in every form without prop threading.
  6. 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.
What useActionState replaces, and what it does not The pending flag is handled by useActionState's isPending or useFormStatus. Storing the result is handled by the returned state. Ordering of rapid submissions is handled because actions are queued. Preventing default submission is handled by the action prop. Keeping user input after a failed validation still requires returning values and using them as defaults. Per-field blur validation, focus management after errors and double-submit protection on the server still require your own code. Concern Handled by React 19 Still yours pending flag isPending / useFormStatus result storage returned state ordering of submits actions queue in order keeping input on failure return values; use as defaults blur-time field feedback your field hook focus after errors move focus to summary or field idempotency server-side keys

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.

The three states and their UI In the idle state the form renders the initial values and no messages. In the invalid state it renders the submitted values as defaults, each field's error linked with aria-describedby, and any form-level error in an alert, and moves focus to the first problem. In the saved state it renders the saved values, remounts the form so it is pristine, and announces profile saved through a status region. idle Initial values. No messages. invalid Submitted values as defaults. Field errors + optional alert. Focus to the first problem. saved Saved values; form remounted. "Profile saved." in a status region.

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.


Related

← React Form Hook Architecture