useActionState wires a form action to state, returning the current result, an action to pass to the form, and a pending flag. It replaces the common pattern of a loading boolean, a manual try/catch and a separate error state, and it keeps the result and the pending state in sync with the action.
Before you start
You should be comfortable with controlled inputs, form submission and async functions. This article assumes React 19-style actions.
Step-by-step walkthrough
Step 1: Write an action that returns the next state
The action receives the previous state and the form data and returns the next state, usually a validation result or a message. Returning a value instead of throwing keeps the error path explicit and testable, and the returned value becomes the state the component reads.
Step 2: Pass the action to the form
const [state, formAction, pending] = useActionState(action, initial) gives you a formAction for the form’s action attribute and a pending flag for disabling the submit button. The form still submits natively, so it works without JavaScript for a basic round trip.
Step 3: Read the result for errors and success
Render validation errors from state beside the fields and a success message where it belongs. Because the action owns the result, the component does not need separate error and loading state, which removes the chance of them disagreeing.
Worked scenario
The action returns field errors and the pending flag disables the button.
function SignUpForm({ onSignUp }) {
const [state, formAction, pending] = useActionState(
async (_prev, formData) => onSignUp(formData),
{ error: null },
);
return (
<form action={formAction}>
<input name="email" type="email" aria-describedby="email-error" />
{state.error ? <p id="email-error" role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? 'Signing up…' : 'Sign up'}</button>
</form>
);
}Walk through the example
Submitting calls the action with the form data; while it runs, pending is true so the button is disabled and labeled. The action returns { error }, and the component renders the message with a role="alert" so it is announced. On success, error is null and the message clears.
Common mistake
Managing a loading flag and an error string separately from the action, which can drift out of sync when two submissions overlap. Another is throwing inside the action for validation errors, which routes them to an error boundary instead of to the form.
Verify the behavior
Submit invalid data and assert the error renders and the button re-enables. Submit twice quickly and confirm the pending flag prevents a duplicate request. Test the no-JavaScript fallback by disabling JavaScript and confirming the form still submits.
Interview exercise
Why return a validation error from the action instead of throwing it?
Answer and reasoning
A validation error is an expected outcome, not a bug, so returning it keeps it in the normal control flow where the form can render it beside the field. Throwing sends it to the nearest error boundary, which is meant for unexpected failures and shows a fallback UI rather than an inline message. Expected results belong in state; exceptions belong to the boundary.
Continue learning
Compare input handling in Controlled and uncontrolled inputs and Reducer invariants. Read the React useActionState reference and try the React interview questions.