Most React forms in production are a useState per field, a submitting boolean, an error string, and a try/finally that resets the boolean. That code works. React 19 shipped a different shape for it — actions, useActionState, useFormStatus, useOptimistic, and the <form action={...}> prop — and the question worth answering is not whether the new shape is nicer. It is which of your forms get less wrong with it, and what the migration costs.
We do not convert form code for its own sake. But two categories of bug do disappear with actions, and both show up in reviews often enough to be worth naming.
What an action actually is
An action is a function passed to <form action={fn}>, to formAction on a button, or to startTransition. React calls it with the form's FormData, marks the surrounding transition as pending, and resets the uncontrolled form on success. That is the whole mechanism on the client. Server Functions ("use server") are a separate feature that happens to produce functions usable in the same slot; you can adopt actions in a plain client-side Vite SPA with no server involvement at all.
The pending state is the part that matters. Today most codebases track submission with a boolean that lives in the component that owns the form, and every descendant that needs it — a submit button three levels down in a design system, a disabled fieldset, a spinner in a modal footer — receives it as a prop. useFormStatus reads it from the nearest parent <form> instead:
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return <Button type="submit" disabled={pending}>{children}</Button>;
}
The button is now correct in every form it is placed in, and no caller can forget to thread the prop. On a design system with twenty form screens, that is the single highest-value piece of this API. Note the constraint: useFormStatus reads the parent form, so the hook must be called in a component rendered inside the <form>, not in the component that renders the <form>.
useActionState replaces the result-handling boilerplate
useActionState takes an action and an initial state, and returns the last returned state, a wrapped action, and a pending flag.
type SaveResult =
| { status: 'idle' }
| { status: 'ok'; savedAt: number }
| { status: 'error'; message: string; fieldErrors?: Record<string, string> };
async function saveProfile(_prev: SaveResult, formData: FormData): Promise<SaveResult> {
const parsed = ProfileSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { status: 'error', message: 'Check the highlighted fields.', fieldErrors: toFieldErrors(parsed.error) };
}
const res = await api.saveProfile(parsed.data);
if (!res.ok) return { status: 'error', message: res.error };
return { status: 'ok', savedAt: Date.now() };
}
function ProfileForm() {
const [state, formAction, isPending] = useActionState(saveProfile, { status: 'idle' });
return (
<form action={formAction}>
<Field name="displayName" error={state.status === 'error' ? state.fieldErrors?.displayName : undefined} />
{state.status === 'error' && <Alert>{state.message}</Alert>}
<SubmitButton>Save</SubmitButton>
</form>
);
}
Two things changed beyond line count. The action is a plain async function of (previousState, formData) — no hooks, no component scope — so it is testable without rendering anything. And the result is one value with a discriminated status instead of three independent pieces of state that can disagree. The error string left over from the previous submit while the next one is pending is a state you can no longer represent.
Rejected errors are the trap. If the action throws, the error propagates to the nearest error boundary; it does not land in state. For expected failures — validation, 409s, declined payments — return them. Reserve throwing for the genuinely unexpected, and make sure there is a boundary above the form.
useOptimistic is narrower than it looks
useOptimistic gives you a value that shows the intended result while the action is pending and snaps back when it settles. It is the right tool for list mutations with a high success rate and a cheap reversal: toggling a like, adding a tag, marking an item done.
const [shownItems, addOptimisticItem] = useOptimistic(items, (current, pending: Item) => [...current, pending]);
It is the wrong tool for anything where a rollback is confusing or expensive — payments, destructive deletes, or a form that navigates away on success. The user sees the operation succeed, then sees it un-succeed, and the recovery UI you then have to write is more code than the spinner you were trying to avoid. Ask what the failure rate is for that specific endpoint before reaching for this. If nobody knows, that is the measurement to take first.
Where this collides with your existing form library
Most codebases already have React Hook Form or Formik. Actions do not replace them. React Hook Form handles per-field validation timing, dirty tracking, array fields, and controlled third-party inputs; actions handle submission, pending state, and the result. On an app with complex forms, the pragmatic arrangement is React Hook Form for field state with its handleSubmit invoked inside an action, or — if the forms are simple — uncontrolled inputs with name attributes, FormData, and a schema parse in the action, which removes the library entirely.
The decision is per-form, not per-app. A 4-field settings panel and a 40-field underwriting wizard do not want the same machinery.
One more constraint worth knowing before planning: passing a function to <form action> requires React 19 on the client. useFormStatus and useActionState are in react-dom and react respectively as of 19; the 18-era useFormState from react-dom is the earlier spelling and is deprecated. If you are still on 18, this is an argument for sequencing the 19 upgrade, not for polyfilling.
How we would sequence it on an existing codebase
- Upgrade to React 19 and let it settle. Nothing here is worth a partial upgrade.
- Convert the design system's submit button and fieldset to read
useFormStatus. This is a small diff and it fixes the "spinner didn't show in the modal" class of bug across every screen at once. - Convert one medium-complexity form to
useActionStatewith a discriminated result type. Read the diff with the team before doing a second. - Leave the wizard alone until the simple forms are done and the pattern has a house style.
- Add
useOptimisticonly where you can name the endpoint's failure rate.
The honest summary: useFormStatus is a clear win on any codebase with a shared design system, useActionState is a modest simplification that mostly buys you testable submit logic and fewer contradictory states, and useOptimistic is a specialist tool that is easy to misapply. None of it justifies a forms migration project. All of it is worth adopting on the next form you touch.