Forms, Actions and optimistic UI
A form action runs as a Transition; pending flags drive the button, and an optimistic value lives only while the Action runs.
Predict first
Before you read: predict
ExamHall candidate app. The candidate has answered every question and clicks Submit exam. The submission service is overloaded and answers 503 after 2 seconds (fetch resolves; res.ok is false).
Which sequence does the badge show, from the click (request in flight) to the 503?
function SubmitButton() { const { pending } = useFormStatus(); return <button disabled={pending}>Submit exam</button>;} function SubmitExamForm() { const [status, setStatus] = useState('In progress'); const [shownStatus, setShownStatus] = useOptimistic(status); async function submitExam(formData) { setShownStatus('Submitted'); const res = await postSubmission(formData); // answers 503 if (res.ok) startTransition(() => setStatus('Submitted')); } return ( <form action={submitExam}> <span>Status: {shownStatus}</span> <SubmitButton /> </form> );}The 30-second version
A function passed to a form's action runs as an Action with the form's FormData. While it runs, isPending from useActionState, and useFormStatus().pending inside the form, are true. useOptimistic shows its temporary value only during the Action. Afterwards the real state shows again, so if nothing updated it, the change rolls back.
A form action runs as a Transition; pending flags drive the button, and an optimistic value lives only while the Action runs.
How it works
Step 1 of 8
value plus onChange makes state the truth. defaultValue plus name leaves it in the DOM, read from FormData. Both are valid.Analogy: A public library's holds desk (public services)
A reader hands a request slip across the holds desk. The librarian lights a "processing" lamp and closes the slip slot, then pencils the book as "on hold" on the holds board. When the catalogue answers, the board is redrawn from the catalogue, and a fresh blank slip goes out.
| In the analogy | In React |
|---|---|
| The reader fills a slip by hand; the desk reads it when it is handed in | An uncontrolled input, read through FormData |
| The librarian types every letter as the reader dictates it | A controlled input: value plus onChange |
| Handing the slip across the desk | Submitting a form whose action is a function |
| The "processing" lamp and the closed slot | isPending / pending and the disabled button |
| The lamp is wired to its own desk; staff standing at it see it, the manager who set the desk up does not | useFormStatus reports only for a parent <form> |
| The pencil mark on the holds board | The optimistic value |
| The board redrawn from the catalogue; the pencil mark is gone unless the catalogue says "on hold" | After the Action, value shows: confirmed or rolled back |
| A fresh blank slip for the next reader | Uncontrolled fields reset after success |
Breaks down when:
- A librarian must erase the pencil mark by hand. React needs no erase call; the mark exists only while the Action runs. Wrong inference: "I must write rollback code."
- A real desk refuses an incomplete slip. A form action validates nothing;
fngets whateverFormDataholds. Wrong inference: "requiredfields protect the server."
Contrasting case: an onSubmit handler with e.preventDefault() and your own useState flags. Nothing is automatic: no Transition, no pending flag, no reset and no rollback, unless you write each one.
In real terms: React wraps the submission in a Transition, tracks its pending state per hook and per form, renders optimistic state on top of the real state while the Transition runs, and resets uncontrolled fields when the action succeeds.
Examples, run for real
The same components gave the same lines in Chromium with real mouse clicks.
Example 1: ExamHall EXH-527, "Save answers said Saved after a 409"
The exam had already closed in another tab, so the server answered 409. The BUG version sets a plain state flag at the start of the Action. MR !814 keeps the real status in useActionState and shows it through useOptimistic.
// BUG (EXH-527)
async function saveAnswers(formData) {
setSaved(true);
const res = await postAnswers(formData);
if (!res.ok) return; // nothing un-sets the flag
}
// FIX (MR !814)
const [result, dispatchAction] = useActionState(saveAnswers, { status: 'Draft', error: null });
const [shownStatus, setShownStatus] = useOptimistic(result.status);
async function saveAnswers(prev, formData) {
setShownStatus('Saved');
const res = await postAnswers(formData);
if (!res.ok) return { status: prev.status, error: `Not saved (${res.status}). Reload the exam.` };
return { status: 'Saved', error: null };
}
// <form action={dispatchAction}> … <SaveButton /> (useFormStatus inside)
BUG server 409: waiting -> Draft || answered -> Saved
BUG server 200: waiting -> Draft || answered -> Saved
FIX server 409: waiting -> Saved || answered -> Draft | Not saved (409). Reload the exam.
FIX server 200: waiting -> Saved || answered -> Saved
The BUG is wrong twice: its update waits for the Action (step 4), then stays. The FIX shows the mark at once and falls back to the returned state (steps 3, 7).
Example 2: chat composer, controlled and uncontrolled after a successful send
to is uncontrolled (defaultValue); text is controlled by draft state.
server got to=Mona text=see you at 6
while sending : to="Mona" text="see you at 6"
after success : to="" text="see you at 6"
Both values reached the action through FormData (steps 1–2). Success reset only the uncontrolled field (step 8); to clear text, call setDraft('') yourself.
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
(p1-support-reset.mjs, 50 runs, 1 distinct output; Chromium parity)
A support form has one uncontrolled field, <input name="subject" defaultValue="" />, and action={openTicket}, where async function openTicket(formData) { const res = await fetch('/tickets', …); if (!res.ok) return; }. The customer types "Refund not received" and submits; the server answers 500.
What is in the subject field afterwards?
Question 2 of 5
(p2-payments-args.mjs, 50 runs, 1 distinct output)
The shopper clicks Pay.
What happens?
const [state, dispatchAction, isPending] = useActionState(pay, { error: null, paid: null });async function pay(formData) { const amount = formData.get('amount'); await charge(amount); return { error: null, paid: amount };}// <form action={dispatchAction}><input name="amount" defaultValue="40.00" /> <button disabled={isPending}>Pay</button></form>Question 3 of 5
(p3-ticketing-fill-slot.mjs, 50 runs, 1 distinct output; Chromium parity)
Fill the slot so a double-click on Reserve sends one request.
Answer in one or two lines.
function SeatForm() { const [held, dispatchAction, isPending] = useActionState(reserveSeat, 0); return <form action={dispatchAction}><button disabled={____}>Reserve</button> held={held}</form>;}Question 4 of 5
A chat app's status form has const [status, setStatus] = useState('Online'), shows Status: {status}, and uses <form action={saveStatus}> with a <select name="status"> and a Set button, where async function saveStatus(formData) { const res = await postStatus(formData.get('status')); startTransition(() => setStatus(res.status)); }. The user sets Busy, then sets Away before Busy's answer. The network delivers Away's answer first and Busy's last. The server stored Away, yet the badge ends on Busy.
Which one change makes the badge end on Away?
Question 5 of 5
A payments back office's NotesPage renders a page header with <SaveIndicator saving={…} />, which shows "Saving…" or "All changes saved", and below it <form action={saveNote}> with a note field and a Save note button. saveNote is a plain async function that awaits the server. SaveIndicator sits in the header, outside the <form>, and must stay there.
Which change makes the header show "Saving…" while the request is open and "All changes saved" after it?
Go deeper
Pro tips
- In tests, make the server a promise you resolve by hand; assert the pending screen, then answer it.
- When it helps: checking optimistic and pending UI (steps 5, 7) with no timers in the assertion.
- It prints the
waiting ->screen before the answer, and it gave 1 distinct output in 50 runs at 8 parallel. - Status: RAN (React 19.3.0, jsdom 30.1.1). Also: submit through a click on the submit button.
form.requestSubmit()or a rawsubmitevent bypasses a disabled button (submit-paths.mjs: 2 requests, against 1 for a click; the same in Chromium).
- Call a
useActionStatedispatch outside a form only insidestartTransition.- When it helps: a Retry button or a keyboard shortcut that triggers the same action (steps 3–4).
- A bare
dispatch()inonClicklogsAn async function with useActionState was called outside of a transition.;startTransition(() => dispatch())logs nothing. - Status: RAN. The message is logged in development builds; the production build was not checked.
Sources
- react.dev,
<form>: https://react.dev/reference/react-dom/components/form. Coversactionas a function receivingFormDatain a Transition, nopreventDefault, POST regardless ofmethod, "After theactionfunction succeeds, all uncontrolled field elements in the form are reset", and errors reaching an Error Boundary. Fetched and confirmed; valid as of 2026-09-27. - react.dev,
useActionState: https://react.dev/reference/react/useActionState. Current signatureconst [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?); the action receives the previous state, then the payload (FormDatafor a form); "React queues and executes multiple calls todispatchActionsequentially"; calls outside a Transition "log an error" in development. Fetched and confirmed; valid as of 2026-09-27. - react.dev,
useOptimistic: https://react.dev/reference/react/useOptimistic.const [optimisticState, setOptimistic] = useOptimistic(value, reducer?); the optimistic state equalsvalue"unless an Action is pending"; on failure "the UI shows what it showed before the optimistic update". Fetched and confirmed; valid as of 2026-09-27. - react.dev,
useFormStatus: https://react.dev/reference/react-dom/hooks/useFormStatus.const { pending, data, method, action } = useFormStatus(); it "will not return status information for a<form>rendered in the same component". Fetched and confirmed; valid as of 2026-09-27. - Supporting: react.dev
<input>(controlledvaluevsdefaultValue): https://react.dev/reference/react-dom/components/input, anduseTransitioncaveats (updates afterawaitneed anotherstartTransition): https://react.dev/reference/react/useTransition. Both fetched 2026-09-27. - Out of scope, one link: Next.js Server Actions, https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations (not fetched or checked).
Related lessons
Checked against the tools and docs as of 2026-09-27.