Ch. 3 · React

React Form Actions with useActionState

Manage form submission, pending state and server errors with useActionState instead of hand-rolled loading flags.

~2 min readintermediateupdated Oct 5, 2026

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>
  );
}
TSX

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.

More in React

read ✓React · hard

React Context Splitting for Performance

Stop a single context value from re-rendering every consumer by splitting state and dispatch and memoizing the provider value.

~2 min readread →
read ✓React · mid

React lazy Loading and Code Splitting

Split bundles with lazy and Suspense, work with default exports, and place boundaries so a chunk loads only when needed.

~2 min readread →
esc