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
statehas 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.
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.
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.
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
Predict the queue
In the first example add a third
f.requestSubmit(). Predict the logs and the final total, then run. - 2
Remove the values trick
In the signup example, remove
defaultValue={state.values[name]}from the input. Submitasha/12. Predict what the fields show next to the errors. (They are empty: the automatic reset cleared them.) - 3
Dispatch without a form
In the first example add a button
onClick={() => startTransition(() => formAction(new FormData(document.querySelector("form"))))}(importstartTransitionfromreact). DoesisPendingstill work?
Code & diagrams
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
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=falseThe 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
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!"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.
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.
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
isPendingto 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
useActionStatecall insidetry/catch: React interrupts render with an internal exception, and catching it logsSuspense Exception: This is not a real error! It's an implementation detail of useActionState....
Remember this
- 1
The signature.
useActionState(action, initialState, permalink?)returns[state, dispatch, isPending].statestarts asinitialStateand afterwards is **whateveractionreturned** the last time.dispatch(often namedformAction) is the function you pass to<form action>, a button'sformAction, or call yourself insidestartTransition.isPendingistruewhile a call is running or waiting. It is imported from **react**. - 2
Your action gets the previous state first. React calls it as
action(previousState, payload). For a form,payloadis theFormData. This is like a reducer (Topic 9.4), but it may beasync, and its return value (or the value its promise resolves to) becomes the new state. You don't callsetStateat all: the return value *is* the update, so the "wrap updates afterawait" problem from Topic 19.2 disappears. - 3
Calls are queued. If
dispatchis called again while a previous call is still running, React waits and runs the next one with the previous result aspreviousState. 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
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 (witharia-invalidandaria-describedby, Topic 5.6) and sets each field'sdefaultValuefromstate.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
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
dispatchonly from a form or insidestartTransition; calling it from a plainonClickmakes React warnAn async function with useActionState was called outside of a transition.andisPendingdoesn't update. Don't wrap theuseActionStatecall itself intry/catch. - 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
useFormStateinreact-dom, withoutisPending. React 19 still exportsuseFormStatefromreact-dombut warns that it was renamed.
Explain it without notes
What does useActionState return, and what does the action receive?
How does useActionState handle several quick submissions?
Describe the validation errors pattern with useActionState.
What is the permalink argument for?
Practice
Write a like counter with useActionState whose action waits 200 ms and returns prev + 1, with a button that says Liking... while pending.
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
useActionStateand 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
useFormStatewas renamed.