UI build: a form with React 19 Actions and validation
Everything the form shows after a submit comes from the state the action returns, because React resets each field to its default.
Predict first
Before you read: predict
ExamHall's test-centre booking form. The candidate's account fills in the email, and one seat is the default:
The candidate changes seats from 1 to 3 and clicks Book. The centre answers 409 with { "field": "seats", "message": "Only 1 seat left" }.
Once "Only 1 seat left" is on screen, what does the seats field show?
const initial = { values: { email: 'ana@uni.example', seats: '1' }, errors: {} }; async function book(prevState, formData) { const res = await reserve({ email: formData.get('email'), seats: formData.get('seats') }); if (res.status === 409) { const { field, message } = await res.json(); return { ...prevState, errors: { [field]: message } }; } return initial;} function BookingForm() { const [state, dispatchAction, isPending] = useActionState(book, initial); return ( <form action={dispatchAction}> <input name="email" defaultValue={state.values.email} /> <input name="seats" defaultValue={state.values.seats} /> {state.errors.seats && <p id="seats-error">{state.errors.seats}</p>} <button disabled={isPending}>Book</button> </form> );}The 30-second version
A form action returns the form's next state, and React resets every uncontrolled field to its defaultValue. So everything the form shows lives in that state: this submit's typed values, an error per field and a form error. Validate before sending, catch a dropped request instead of throwing, tie each error to its field, and let the server check again.
Everything the form shows after a submit comes from the state the action returns, because React resets each field to its default.
How it works
Step 1 of 11
{ values, errors, formError }; whatever the form shows after a submit comes from the action's return.Analogy: A village produce show's entry tent (country fairs)
An exhibitor fills in an entry form by hand and hands it across the tent's table. The show secretary checks the class rules, phones the judges' tent, and hands back an entry card printed from the show laptop.
| In the entry tent | In the form |
|---|---|
| the exhibitor's handwriting on the form | the typing in uncontrolled fields |
| the rules checked before the judges' tent is phoned | validation before any request (step 2) |
| every card handed back is printed from the laptop | the reset to each field's defaultValue (step 3) |
| the exhibitor's answers typed into the laptop before printing | the typed values returned in the state (step 4) |
| last year's card reprinted for that exhibitor | { ...prevState }, the previous result (step 5) |
| a red sticker on the faulty box, with a numbered note below it | aria-invalid plus aria-describedby (step 7) |
| the phone line drops: the card is printed anyway, "not registered yet" on top | the rejection caught; formError and the values returned (step 8) |
| the tent shut instead, and the queue sent home | an escaped throw: the Error Boundary, queued submits skipped (step 9) |
| the judges' tent re-checks every entry, including those brought by hand | the server validating again (step 10) |
In real terms: an action turns the last result and this submit's FormData into the next screen; React then resets the fields to the defaults that screen declares.
Breaks down when: a printer is slow and you watch the blank card; the reset and the refill land in one commit, so no empty frame shows. A secretary also remembers what you said; React keeps only what the action returned.
Contrasting case: an onSubmit handler that calls preventDefault() and then dispatches inside startTransition. Nothing is reset, so nothing needs refilling, but you clear the fields yourself, and onSubmit cannot take a Server Function (react.dev <form>).
Examples, run for real
4.1 ExamHall EXH-2900: the refusal that emptied the booking form
The candidate asked for 3 seats where 1 was left, and the centre answered 409 { field: 'seats', message: 'Only 1 seat left' }. The draft action returned blank values with the message as formError, so the message sat at the top and both fields came back empty (EXH-2900). MR !3200 returns this submit's values and files the message under its field:
// MR !3200, the 409 branch
const values = Object.fromEntries(formData);
const res = await reserve(values);
if (res.status === 409) {
const { field, message } = await res.json();
return { values, errors: { [field]: message }, formError: null };
}
4.1 EXH-2900 409: requests 1 · email "" · seats "" · message at the top · seats invalid no · seats description ""
4.1 MR !3200 409: requests 1 · email "ana@uni.example" · seats "3" · message after seats · seats invalid yes · seats description "Only 1 seat left"
The draft lost both fields to the reset (step 3). The MR's state carries the typing back (step 4), and its field markup ties the message to seats (step 7).
4.2 Chat: a rename refused before any request
A chat app's rename-channel form checks the name in its action and returns the error with the typed name. The second run sends a valid name; the third wires an action that returns no values through onSubmit:
4.2 name "a!": requests 0 · field "a!" · message "Use 3 to 30 lowercase letters, digits or dashes"
4.2 name "release-notes": requests 1 · the server got "release-notes"
4.2 onSubmit with preventDefault, an action that returns no values: after the refusal the field still holds "a!"
The refusal sent nothing (step 2) and kept the typing (step 4). Through onSubmit, React reset nothing at all: the contrasting case.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A chat app's composer sends through useActionState, so sends run one after another, and its Send button is never disabled:
The user sends "hi", then sends "there" while "hi" is still in flight. Then the connection drops, and sendMessage('hi') rejects with TypeError: Failed to fetch; the server would answer any later call.
How many times is sendMessage called, and what is on screen?
async function send(prevState, formData) { await sendMessage(formData.get('text')); return { sent: prevState.sent + 1 };} function Composer() { const [state, dispatchAction] = useActionState(send, { sent: 0 }); return ( <form action={dispatchAction}> <input name="text" /> <button>Send</button> <span>{state.sent} sent</span> </form> );} function ChatPane() { return ( <ErrorBoundary fallback={<p>Chat is unavailable</p>}> <Composer /> </ErrorBoundary> );}Question 2 of 8
A streaming service's gift-code form sits inside an Error Boundary whose fallback reads "Something went wrong":
A viewer types GIFT-2026 and clicks Redeem; the connection drops, and redeemCode rejects.
Which catch body leaves the form on screen with "No connection, try again" and GIFT-2026 still in the field?
function refusal(formData, message) { return { values: { code: formData.get('code') }, error: message };} async function redeem(prevState, formData) { let res; try { res = await redeemCode(formData.get('code')); } catch { /* the catch body */ } if (!res.ok) return refusal(formData, 'That code is not valid'); return { values: { code: '' }, error: null };} function GiftCodeForm() { const [state, dispatchAction] = useActionState(redeem, { values: { code: '' }, error: null }); return ( <form action={dispatchAction}> <input name="code" defaultValue={state.values.code} /> {state.error && <p role="alert">{state.error}</p>} <button>Redeem</button> </form> );}Question 3 of 8
A payments app's transfer form refuses an amount over the daily limit: its action returns errors.amount = 'Above your daily limit'. Every version below keeps <label htmlFor="amount">Amount</label> before the field, except where it says otherwise.
Which version makes a screen reader, on reaching the field, say "Amount", that it is invalid, and then read the message?
Question 4 of 8
A box office's booking form returns values: { seats: formData.get('seats'), access: formData.get('access') } after a refusal (an unticked box sends nothing, so access is null; a ticked one sends 'on'). Its fields:
A fan ticks Wheelchair space, enters 2 seats and clicks Book; the box office answers 409. The seats field shows 2 again, but the box is unticked.
Which change brings the tick back and still lets the fan untick it?
<input name="seats" defaultValue={state.values.seats} /><label> <input type="checkbox" name="access" defaultValue={state.values.access} /> Wheelchair space</label>Question 5 of 8
the interviewer says: "Build the test-centre booking form: email and seats, validation, a pending state. reserve({ email, seats }) resolves to { status, json() }: 201 when it books, 409 with { field, message } when it refuses. It rejects when the connection drops." Seats must be a whole number from 1 to 4, and the email must contain @. You have 25 minutes and a plain editor.
Before you type: say your test list out loud.
Name at least five tests, each with what the user does and what the form must show.
Question 6 of 8
the interviewer says: "Build the test-centre booking form: email and seats, validation, a pending state. reserve({ email, seats }) resolves to { status, json() }: 201 when it books, 409 with { field, message } when it refuses. It rejects when the connection drops." Seats must be a whole number from 1 to 4, and the email must contain @. You have 25 minutes and a plain editor.
Now write it, in 25 minutes, as a module that exports BookingForm({ reserve }).
Question 7 of 8
A chat app's composer can delay a message: its Send later form takes the delay in minutes.
A member types 90m and clicks Send later. The message appears, and the field shows NaN.
Which change makes the field show 90m with the message, and still has a valid entry such as 30 reach scheduleMessage as the number 30?
async function scheduleSend(prevState, formData) { const minutes = Number(formData.get('minutes')); if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440) { return { values: { minutes }, error: 'Use whole minutes from 1 to 1440' }; } await scheduleMessage(draftId, minutes); return { values: { minutes }, error: null };} function SendLaterForm() { const [state, dispatchAction] = useActionState(scheduleSend, { values: { minutes: '60' }, error: null }); return ( <form action={dispatchAction}> <input name="minutes" inputMode="numeric" defaultValue={state.values.minutes} /> {state.error && <p>{state.error}</p>} <button>Send later</button> </form> );}Question 8 of 8
A shop's review form keeps what the shopper typed when a review is refused, but after a review is accepted its title and text are still in the fields:
Shoppers are often refused once (422), then accepted after they add a line.
Which change leaves both fields blank after every accepted review, that one included, and still keeps the typed text after a 422?
function ReviewForm() { const formRef = useRef(null); async function postReview(prevState, formData) { const values = { title: formData.get('title'), text: formData.get('text') }; const res = await submitReview(productId, values); if (res.status === 422) { return { values, errors: (await res.json()).errors, posted: false }; } return { values, errors: {}, posted: true }; } const [state, dispatchAction] = useActionState(postReview, { values: { title: '', text: '' }, errors: {}, posted: false }); return ( <form action={dispatchAction} ref={formRef}> <input name="title" defaultValue={state.values.title} /> <textarea name="text" defaultValue={state.values.text} /> {state.posted && <p>Thanks for your review</p>} <button>Post review</button> </form> );}Go deeper
Pro tips
- In a test, read the fields after the server answers, not before. When: checking a refusal or a success (steps 3–4): the reset lands when the action returns, so a check made while the request is open still sees the typing that the answer may wipe. Verify (RAN,
tips.mjs):tip 1 the EXH-2900 draft: while the request is open seats "3" · after the 409 answer seats "" - To see exactly what an action will receive, print the form's entries in the browser console:
Object.fromEntries(new FormData(document.querySelector('form'))). When: a field arrives asnullor missing (step 2). Disabled fields and unticked boxes are not in it. Verify (RAN,tips.mjs):tip 2 entries of a form with a disabled field and an unticked box: {"email":"ana@uni.example"}
Source
Vendor pages change, so their claims are labelled as of 2026-10-04; every behaviour this lesson grades is executed on React 19.3.0.
- react.dev,
useActionState(the curriculum row's first source), https://react.dev/reference/react/useActionState (react-useactionstate.txt). The reducer action receives the previous state, then the payload (steps 1, 5); "For known errors, such as “quantity not available” validation errors from your backend, you can return it as part of your reducerAction state and display it in the UI" (step 6); "When a reducerAction throws, React skips all subsequently queued dispatchAction calls", with the advice to "catch errors within your reducerAction and return an error state instead of throwing" (steps 8–9). As of 2026-10-04. - react.dev,
<form>(the row's second source), https://react.dev/reference/react-dom/components/form (react-form.txt). "After the action function succeeds, all uncontrolled field elements in the form are reset" (step 3); a thrown error shows the Error Boundary's fallback (step 9); "An action can also be a Server Function, which onSubmit does not support" (the contrasting case). As of 2026-10-04. - react.dev,
useFormStatus(the row's third source), https://react.dev/reference/react-dom/hooks/useFormStatus (react-useformstatus.txt). The pending flag of a parent<form>; this lesson shows pending throughisPendingand links the prerequisite for the choice (step 11). As of 2026-10-04. - react.dev,
'use server', https://react.dev/reference/rsc/use-server (react-use-server.txt): "Arguments to Server Functions are fully client-controlled. For security, always treat them as untrusted input, and make sure to validate and escape arguments as appropriate." (step 10). And Server Functions, https://react.dev/reference/rsc/server-functions (react-server-functions.txt). As of 2026-10-04. - MDN,
aria-describedby, https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-describedby (mdn-aria-describedby.txt), andaria-invalid, https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid (mdn-aria-invalid.txt): "set aria-invalid="true" and tell the user there is an error" (step 7). MDN also namesaria-errormessagefor the message; this lesson teaches and gradesaria-describedby, whose descriptiondom-accessibility-api0.7.1 computes, and does not gradearia-errormessage. P3 grades the computed name, state and description; the step to what a screen reader says is a stated rule, not a run: MDN gives the description's reach, "assistive technology users can immediately access it as the description is referenced from that form control with aria-describedby", and the order (name, then state, then description) is this lesson's statement, which no saved source gives (R4 ruling 1). As of 2026-10-04.
Related lessons
Checked against the tools and docs as of 2026-10-04.