Command Palette

Search for a command to run...

Hectal
PHASE 19Advanced React 19+ ~23 min· topic 4 of 8

Topic 19.4

useActionState

In one line

const [state, formAction, isPending] = useActionState(fn, initialState) wraps an action so React keeps whatever it returned last as state, tells you while it runs, and queues calls one after another; it is the standard way to show a form's result and validation errors.

Think of it like this

A school exam desk. You hand in your answer sheet; the teacher marks it and hands back a result slip. If several students hand in at once, the teacher marks them one at a time, in the order they arrived. The slip on your desk is always the latest result, and while marking is going on, a little "marking..." sign is up. useActionState is that teacher: your function marks the work and returns the slip, React keeps the slip as state, and isPending is the sign.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

useActionState
A hook that runs an action, keeps its last return value as state and reports whether it is running.
Initial state
The value state has before the action has run even once.
Dispatch / formAction
The function returned by the hook. Pass it to a form or call it inside startTransition.
Previous state
The value the action returned last time. React passes it as the first argument.
Payload
The second argument: the FormData for a form, or whatever you pass to dispatch.
Queue
A line where calls wait their turn. Each dispatch runs after the previous one finishes.
Permalink
A URL used by frameworks so a form works before JavaScript loads. Optional.

Step by step

01Wrap your action

Write the action as (previousState, formData) => newState and give the hook a starting value. Use the returned formAction as the form's action.

Cart.jsxwhole filejsx
async function addToCart(prev, formData) {
  const qty = Number(formData.get("qty"));
  const total = await api.add(qty);
  return { total, message: "Added " + qty };
}

const [state, formAction, isPending] = useActionState(addToCart, { total: 0 });

<form action={formAction}>...</form>

02Render from state

state is the last return value. Show the result, the message, or errors from it. isPending disables the button while the action runs.

03Calls wait in line

A second submit while the first is running doesn't start in parallel. It waits, then receives the first call's result as previousState.

Calls wait in linediagram
Rendering diagram…

04Return errors instead of throwing them

For input problems, return { errors, values }. The fields read defaultValue from state.values, so after the automatic reset they show what the user typed, with the messages beside them.

Signup.jsxwhole filejsx
if (errors) return { errors, values };
return { errors: {}, values: { email: "" }, message: "Welcome!" };

<input name="email" defaultValue={state.values.email}
  aria-invalid={state.errors.email ? true : undefined} />

05Calling it without a form

dispatch can take any payload. Outside a form, call it inside startTransition so isPending works: startTransition(() => dispatch({ type: "refresh" })).

Try it yourself

  1. 1

    Predict the queue

    In the first example add a third f.requestSubmit(). Predict the logs and the final total, then run.

  2. 2

    Remove the values trick

    In the signup example, remove defaultValue={state.values[name]} from the input. Submit asha / 12. Predict what the fields show next to the errors. (They are empty: the automatic reset cleared them.)

  3. 3

    Dispatch without a form

    In the first example add a button onClick={() => startTransition(() => formAction(new FormData(document.querySelector("form"))))} (import startTransition from react). Does isPending still work?

Code & diagrams

Results become state, and calls queue React 19+ New tab

The second call starts only after the first returns, and gets `prev=5`. React commits the final state once the queue is empty, so the screen goes 0 → 10 without flashing 5.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render total=0 pending=false
action 1 starts, prev=0
render total=0 pending=true
action 1 returns 5
action 2 starts, prev=5
action 2 returns 10
render total=10 pending=false
Validation errors that keep what the user typed React 19+ New tab

The first submit returns errors and the typed values; after the automatic reset the fields show those values again (they are the new `defaultValue`s). The second submit succeeds and returns empty values, so the form clears.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render pending=false errors= message=-
render pending=true errors= message=-
render pending=false errors=age,username message=-
username box after errors: asha
render pending=true errors=age,username message=-
render pending=false errors= message=Welcome, meera!
The permalink argument (framework code, not live)tsx
"use client";
import { useActionState } from "react";
import { addToCart } from "./actions"; // a Server Function ("use server"), Phase 20

export function AddButton({ productId }: { productId: string }) {
  // Before JavaScript loads, the form posts to /products/<id>; the server
  // runs addToCart and renders that page with the new state.
  const [message, formAction, isPending] = useActionState(addToCart, null, "/products/" + productId);
  return (
    <form action={formAction}>
      <input type="hidden" name="productId" value={productId} />
      <button disabled={isPending}>Add to cart</button>
      {message && <p>{message}</p>}
    </form>
  );
}

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Call dispatch outside a transition

Use const [n, dispatch, isPending] = useActionState(async (prev) => prev + 1, 0) and call dispatch() directly in a button's onClick.

terminal
$ Console
── what you'll see ──
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.

Break #2

Use the old name from canary tutorials

Write import { useFormState } from "react-dom" and const [count, action] = useFormState((prev) => prev + 1, 0); in a Counter component.

terminal
$ Console
── what you'll see ──
ReactDOM.useFormState has been renamed to React.useActionState. Please update Counter to use React.useActionState.

Myth vs fact

Myth

useActionState is only for forms.

Fact

It works with any payload. Forms are the common case, but you can dispatch objects inside startTransition, like an async reducer.

Myth

Each submit runs at the same time.

Fact

Dispatches are queued: each runs after the previous one finished and receives its result.

Myth

Throwing is the way to show validation errors.

Fact

Throwing goes to an error boundary and replaces the UI. Return error data for problems the user can fix.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    React keeps the queue per hook instance. The state you see updates only when the queue is empty, which avoids flashing intermediate results, but long queues mean long pending states: disable the button with isPending to avoid building one.

  • ▸

    Because the action receives previousState, it can implement optimistic counters or pagination (return [...prev, ...nextPage]) without reading component state, so it can live outside the component or on the server.

  • ▸

    Next.js and other frameworks pass a Server Function as the action; the hook's state then comes from the server response, and with a permalink the same form works before hydration (progressive enhancement). The state must then be serialisable.

  • ▸

    Don't put the useActionState call inside try/catch: React interrupts render with an internal exception, and catching it logs Suspense Exception: This is not a real error! It's an implementation detail of useActionState....

Remember this

  1. 1

    The signature. useActionState(action, initialState, permalink?) returns [state, dispatch, isPending]. state starts as initialState and afterwards is **whatever action returned** the last time. dispatch (often named formAction) is the function you pass to <form action>, a button's formAction, or call yourself inside startTransition. isPending is true while a call is running or waiting. It is imported from **react**.

  2. 2

    Your action gets the previous state first. React calls it as action(previousState, payload). For a form, payload is the FormData. This is like a reducer (Topic 9.4), but it may be async, and its return value (or the value its promise resolves to) becomes the new state. You don't call setState at all: the return value *is* the update, so the "wrap updates after await" problem from Topic 19.2 disappears.

  3. 3

    Calls are queued. If dispatch is called again while a previous call is still running, React waits and runs the next one with the previous result as previousState. Two clicks on "Add 5" give 5 then 10, never two 5s. That makes it safe for counters, carts and anything where each step builds on the last, and avoids the stale-answer race from Topic 19.2. The cost: calls can't overlap, so a slow request delays the next.

  4. 4

    The validation errors pattern. Return an object such as { errors: { email: "Required" }, values: {...} } from the action. The component renders each error next to its field (with aria-invalid and aria-describedby, Topic 5.6) and sets each field's defaultValue from state.values. Because the form resets after every successful call, its fields go back to those defaults, so the user's typing survives a failed submit. On success, return empty values and a message.

  5. 5

    Errors. Returning an error object is for expected problems (bad input, a taken username). If the action throws, React treats it like a render error: the nearest error boundary shows its fallback. Call dispatch only from a form or inside startTransition; calling it from a plain onClick makes React warn An async function with useActionState was called outside of a transition. and isPending doesn't update. Don't wrap the useActionState call itself in try/catch.

  6. 6

    The permalink (third argument) is a URL string used with Server Functions in frameworks (Phase 20). If the form is submitted before JavaScript has loaded, the browser posts to that URL, and the server renders the page there with the action's result, so the form still works. In a client-only app it does nothing. History: during the React 19 canaries this hook was useFormState in react-dom, without isPending. React 19 still exports useFormState from react-dom but warns that it was renamed.

Explain it without notes

01

What does useActionState return, and what does the action receive?

02

How does useActionState handle several quick submissions?

03

Describe the validation errors pattern with useActionState.

04

What is the permalink argument for?

Practice

01

Write a like counter with useActionState whose action waits 200 ms and returns prev + 1, with a button that says Liking... while pending.

02

Write a newsletter action that returns { error: "Enter an email." } for an empty field and { message: "Thanks!" } otherwise, and render the result.

Trade-offs

  • ↔

    Queueing gives correct, ordered results but makes a slow request block the next one; for independent requests (search as you type), plain Actions with stale-answer checks or a data library fit better.

  • ↔

    Returning errors as state keeps the UI in place and works across the server boundary, but the state shape becomes part of your API; keep it small and serialisable.

Done when you can

  • I can wrap an action with useActionState and render its result.

  • I know the action receives the previous state and the payload.

  • I can explain how quick submissions are queued.

  • I can return validation errors and keep the user's values.

  • I dispatch only from forms or inside startTransition.

  • I know what the permalink is for and that useFormState was renamed.