Skip to content

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?

Code for this question
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>  );}
Pick one

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

Controlled or uncontrolled. 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; fn gets whatever FormData holds. Wrong inference: "required fields 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

an older man in a green shirt stands at a blue desk and drops one sheet of paper into a green tray. To the right stands an empty blue chair with a pale ribbon tied in a bow on its backrest. No visitor is in the picture, and there is no text. Read it as: the tray is the form handed to the action; the ribbon is a provisional mark on the seat, the optimistic value.
An illustration to help it stick. The mechanism is in the sections above.

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?

Pick one

Question 2 of 5

(p2-payments-args.mjs, 50 runs, 1 distinct output)

The shopper clicks Pay.

What happens?

Code for this question
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>
Pick one

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.

Code for this question
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?

Pick one

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?

Pick one

Go deeper

Pro tips
  1. 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 raw submit event bypasses a disabled button (submit-paths.mjs: 2 requests, against 1 for a click; the same in Chromium).
  2. Call a useActionState dispatch outside a form only inside startTransition.
    • When it helps: a Retry button or a keyboard shortcut that triggers the same action (steps 3–4).
    • A bare dispatch() in onClick logs An 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

Checked against the tools and docs as of 2026-09-27.