useState → Actions: async form state in one hook
Before React 19, a single form submission meant hand-rolling 3-4 pieces:
useState for the fields, useState for pending,
useState for error, a try/catch/finally around
the fetch, and manual disable/enable of the submit button. React 19
collapses that into Actions — async functions passed to
<form action> or wrapped with startTransition, plus three
hooks that read the resulting lifecycle.
| hook | signature | role |
|---|---|---|
useActionState |
[state, formAction, isPending] = useActionState(reducerAction, init) |
"useReducer for side effects" — reducerAction may be async, may POST. Returns the result + a dispatcher + a pending flag. |
useFormStatus |
{ pending } = useFormStatus() (child of <form>) |
Reads the parent form's pending state from React's internal context — a deep <SubmitButton/> needs zero props. |
useOptimistic |
[optimisticValue, addOptimistic] = useOptimistic(realValue) |
Layers a predicted value on top during the pending window; React auto-reverts to realValue when the action resolves. |
API probe (runtime):
useActionState=… ·
useFormStatus=… ·
useOptimistic=….
This page simulates the action lifecycle with useReducer (idle→pending→success)
and the optimistic overlay with useState — the pattern is identical; the
simulation just sidesteps the <form action> React-internal context that is
fragile inside an eval sandbox. See the guide for the exact native APIs.
1 · the action state machine (edit me)
2 · Babel compiles JSX → element tree
<ActionsDemo/> becomes React.createElement(ActionsDemo).
Each dispatch({{type:'submit'}}) runs the reducer, React re-renders with
status:'pending'; the setTimeout then dispatches
'success' and the optimistic overlay is discarded.
// (hit "compile & render" to see Babel's output)
3 · live React (the action lifecycle, proven)
The gold-check drives this automatically: asserts
status:"idle" → types a name + clicks Submit → asserts
status:"pending" (action started, optimistic value shown) → waits for the
simulated async → asserts status:"success" with the committed name. That proves
the idle→pending→success state machine and optimistic reconciliation end-to-end.
status: — · gold: idle → pending → success + committed name
useActionState vs useReducer
| aspect | useReducer | useActionState |
|---|---|---|
| reducer purity | MUST be pure — no fetch, no timers |
reducerAction MAY be async & run side effects |
| return value | [state, dispatch] (2 items) |
[state, dispatchAction, isPending] (3 items) |
| dispatch contract | call anywhere | must run inside a startTransition or an Action prop |
| queuing | batched, parallel reducer calls | sequential — each call waits for the previous result |
| built-in pending flag | no — you wire isPending yourself |
yes — third return value |
| best for | UI state machines (todos, steppers) | form submissions, mutations, server actions |
intent → pattern
| intent | pattern | why |
|---|---|---|
| submit a form, track pending | useActionState(asyncAction, init) + pass dispatchAction to <form action> |
React wraps the submit in a Transition; isPending is free |
| show predicted value while waiting | useOptimistic(real) + addOptimistic(pred) inside the action |
instant feedback; React auto-reverts to real on resolve |
| deep submit button knows it's submitting | useFormStatus() in a child of <form> |
no prop-drilling — reads React's form context |
| handle validation / server errors | return {error} state from reducerAction |
known errors → UI; unknown throws → nearest Error Boundary |