React’s useActionState hook wraps an action function so you get three things back: the last result the action returned, a dispatcher to pass to <form action>, and an isPending flag. Your action receives the previous state first and the FormData second. After a successful submission React resets uncontrolled fields, so return the submitted values if you want to keep them.
The reset catches most people out, because a returned validation error still counts as success. Everything below is written against React 19.3, the current release as of October 2026 (see the React versions page).
What does useActionState return?
The reference docs give the signature as:
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink);
reducerAction is called as reducerAction(previousState, actionPayload). On the first call previousState is your initialState; after that it is whatever the last call returned. When you pass dispatchAction to a form’s action prop, the payload is the submitted FormData.
The docs call it a reducer action for a reason. It reduces old state into new state like useReducer, but it may be async and it may have side effects, such as posting to an API. React does not double-invoke it under <StrictMode> for that reason.
If you are reading older tutorials: this hook was ReactDOM.useFormState in the canary releases. The React 19 announcement renamed it and deprecated the old name. Import it from react, not react-dom.
How do you use useActionState with a form?
This sign-up form validates on submit, posts to an API, and keeps what the user typed when either step fails.
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
type SignupState = {
ok: boolean;
message: string | null;
fieldErrors: { email?: string; name?: string };
values: { email: string; name: string };
};
const initialState: SignupState = {
ok: false,
message: null,
fieldErrors: {},
values: { email: "", name: "" },
};
async function signupAction(
_prev: SignupState,
formData: FormData,
): Promise<SignupState> {
const email = String(formData.get("email") ?? "").trim();
const name = String(formData.get("name") ?? "").trim();
const values = { email, name };
const fieldErrors: SignupState["fieldErrors"] = {};
if (!email.includes("@")) fieldErrors.email = "Enter a valid email address.";
if (name.length < 2) fieldErrors.name = "Name needs at least 2 characters.";
if (Object.keys(fieldErrors).length > 0) {
return { ok: false, message: null, fieldErrors, values };
}
try {
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) {
return { ok: false, message: `Sign-up failed (${res.status}).`, fieldErrors: {}, values };
}
return { ...initialState, ok: true, message: "Check your inbox to confirm." };
} catch {
return { ok: false, message: "Network error. Please try again.", fieldErrors: {}, values };
}
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Sending..." : "Sign up"}
</button>
);
}
export function SignupForm() {
const [state, formAction, isPending] = useActionState(signupAction, initialState);
return (
<form action={formAction} aria-busy={isPending}>
<label>
Email
<input name="email" type="email" defaultValue={state.values.email} />
</label>
{state.fieldErrors.email && <p role="alert">{state.fieldErrors.email}</p>}
<label>
Name
<input name="name" defaultValue={state.values.name} />
</label>
{state.fieldErrors.name && <p role="alert">{state.fieldErrors.name}</p>}
<SubmitButton />
{state.message && <p aria-live="polite">{state.message}</p>}
</form>
);
}
Three details matter. The action never throws for an expected failure; it returns an error state instead (more on why below). Every return path has the same shape as initialState, which the docs require and which TypeScript will enforce for you. And the inputs are uncontrolled, fed by defaultValue from state.
useFormStatus lives in react-dom and only reports on a parent <form>, so it has to sit in a child component like SubmitButton. Inside SignupForm itself, use isPending.
Why does my form clear after submit?
Because React resets it. When a function passed to a form’s action prop succeeds, React resets every uncontrolled field, the same as calling form.reset(). Controlled inputs are untouched.
“Succeeds” here means the action resolved. A validation failure that you returned as state resolves too, so the user’s half-filled form is wiped by the first validation error.
The fix in the example above comes from the timing. The pull request that added the reset runs it after the DOM updates from the transition are applied, “so the defaultValue of the inputs are updated before the values are reset”. The idea, in Andrew Clark’s words, is that defaultValue “represents the current, canonical value sent by the server”. Return the submitted values, render them as defaultValue, and the reset restores them. On success, return empty values and the form clears, which is what you want.
Some forms should never reset, such as a “save draft” button. There is no opt-out prop in React 19.3. The issue asking for one is still open as of October 2026, and an autoReset prop has been proposed in an unmerged PR, so do not rely on it. The workaround a React maintainer gives in that thread is to go back to onSubmit and start the transition yourself:
import { startTransition, type FormEvent } from "react";
// dispatchAction comes from useActionState in the same component
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
startTransition(() => dispatchAction(formData));
}
// <form onSubmit={handleSubmit}>
You keep the pending state and the queueing, and lose the reset. Read pending from isPending: the same maintainer’s example comments “use isPending instead of useFormStatus().pending” for this pattern.
Why is isPending not updating?
You called the dispatcher outside a transition. In development React logs this:
“An async function with useActionState was called outside of a transition. This is likely not what you intended (for example, isPending will not update correctly). Either call the returned function inside startTransition, or pass it to an action or formAction prop.”
Passing it to action or formAction wraps it for you. From a click handler, import startTransition from react and call dispatchAction inside it.
The related error, “Cannot update action state while rendering”, means you called the dispatcher during render. Earlier 19.x releases worded it as “form state”; the React changelog for 19.3 records the change. Move the call into an event handler.
What happens when the action throws?
React cancels every queued call and rethrows the error from the hook, so the nearest error boundary renders. Any clicks the user made after the failing one are dropped.
That is fine for genuine bugs. For anything you can predict (a taken email address, an out-of-stock item, a 409 from your API) return it as state. The React docs draw the same line between known and unknown errors.
Why are my submissions slow to land?
Calls are queued and run one after another. Each call waits for the previous one, because it needs that result as previousState. Four clicks on an action that takes a second take about four seconds to settle, and the docs say plainly that this is intentional.
You have three ways out. Pair the hook with useOptimistic so the UI moves on the click and the server catches up. Pass an AbortController signal in the payload so a new call can cut the old one short, though aborting a fetch does not undo a write that already reached the server. Or, if the calls are independent of each other, skip useActionState and use useState with useTransition, which the docs suggest for parallel work.
Does it work with Server Functions?
Yes, and this is where the third argument comes in. If reducerAction is a Server Function, both initialState and the payload must be serialisable: plain objects, arrays, strings, numbers.
permalink is for progressive enhancement. If someone submits before your JavaScript has loaded, the browser loads that URL instead, and React carries the state through as long as the destination renders the same form with the same action and permalink. Once the page is interactive it does nothing. The docs add that your framework typically handles this for you, so you rarely set it by hand.
If you are on Astro rather than a React meta-framework, our Astro Actions write-up covers the same no-JS fallback from the other side. For the validation step itself, Standard Schema lets one schema run in the action whichever library you picked.
When we would reach for it
Use useActionState for any form whose result you show next to it: sign-ups, contact forms, settings pages, add-to-basket buttons. Return errors as state, echo the submitted values back, and let the reset do its job on success. Skip it when submissions need to run in parallel, or when the form must never reset and you would rather not fight onSubmit; a form library with controlled inputs is less work there.
We build React and Next.js front ends for clients, forms and Server Functions included. If you want a second pair of eyes on yours, our React development page explains how we work.