Part 9 · 5 chapters · ~35 min

Forms And Inputs

A controlled input is a render per keystroke; an uncontrolled one is a value in the DOM read at submit; most forms want the second with React state only for what the UI must react to. This part is the two models keystroke by keystroke, validation timing and schemas, actions with useActionState and useFormStatus and progressive enhancement, large forms that do not lag, and the money form with every defence it needs.

44

Controlled and uncontrolled

the question

"The docs say controlled inputs. The form lags on a phone. What is actually happening per keystroke?"

A render. A controlled input stores its value in React state, so each keystroke is an event, a state update, a render of the component holding the state (and everything under it that does not bail out), and a commit that compares the DOM's value with the prop. An uncontrolled input keeps its value in the DOM; React sets it once and reads it when asked. The right default for most fields is uncontrolled, with React state reserved for what the UI must react to: errors, dependent options, pending state.

controlled, precisely
  1. value + onChange: React's onChange is the native input event normalised; it fires per keystroke (and per paste, per IME composition update). The handler sets state; the render happens on a SyncLane (part 3); in commit React writes the state's value to the DOM if it differs from what is there.
  2. State wins: if the handler does not update state, React writes the old value back and the input appears frozen. If the handler transforms (uppercase, a mask), the DOM shows the transformed value; the cursor position may jump unless managed (the classic mask bug).
  3. value={undefined} makes the input uncontrolled; switching between defined and undefined warns and breaks. Initialise to "", not null or undefined.
  4. Cost: a render per keystroke of the owning component's subtree. Put the state in the smallest component that needs it (a field component), and the cost is one small render.
uncontrolled, precisely
  1. defaultValue / defaultChecked: set at mount only. To reset, remount with a new key, or call form.reset().
  2. Reading: new FormData(form) at submit (all named fields, files included, in insertion order), or a ref per field. Works without JavaScript, which is what actions (chapter 3) build on.
  3. Reacting to a change without controlling: onBlur for validation after leaving the field; onInput reading e.target.value into a debounced lookup; the browser's constraint validation API (required, pattern, min, setCustomValidity) for free validation UI.
the rule
Control a field when its displayed value must differ from what was typed, when something else must update on every keystroke, or when the value is set programmatically. Otherwise uncontrolled, read at submit, validate on blur and submit.
CONTROLLED VERSUS UNCONTROLLED
who owns the input's value, keystroke by keystroke
swipe the figure sideways, or tap expand for full screen
1/6
controlled: keystroke
Controlled: setName(e.target.value)} />. The user types "a". The DOM input's value becomes "a" (the browser does this). React's onChange fires (a SyncLane update, part 3). setName("a") is queued.
45

Validation: when, where, and with what

code
// validation timing: three moments, one schema
const schema = z.object({ amount: z.coerce.number().int().positive().max(5_000_000), account: z.string().length(10) })
// 1. as you type: only for format feedback (a character count, a mask), never for "required" errors on an untouched field
// 2. on blur: field-level, after the user leaves the field: errors[field] = schema.shape[field].safeParse(value)
// 3. on submit: the whole schema; focus the first invalid field; render an error summary with links (accessibility)
// 4. on the server: the whole schema again. the client validation is UX; the server validation is the rule
// derive, do not store: const errors = useMemo(() => validateAll(values), [values]) when values are in state; or validate(formData) in the action
// async validation (does this account exist): debounce, cancel the previous (AbortController or a query key), show "checking…", never block typing
// the shape libraries agree on: a schema object (zod, valibot, yup) shared by client and server, so the two validations cannot drift
the moments
  1. Never on first paint. A form that opens covered in red errors has validated untouched fields. Track touched (on blur) or submitted (after the first attempt) and show errors only for those.
  2. On blur for field-level rules the user can fix in place: format, length, range. The field they just left; one error, next to it.
  3. On submit for the whole schema and cross-field rules (end after start; total matches sum). Focus the first invalid field; render an error summary at the top with links to each field (screen readers, keyboard users).
  4. Async, debounced, cancellable for anything that needs the server (username availability, account lookup): show "checking", never block typing, cancel the previous check, and treat the result as advisory until the server validates on submit.
  5. On the server, always. Client validation is a convenience; the server's is the rule. Share the schema so they cannot drift.
mechanics
  1. Derive errors, do not store them. With values in state: errors = validate(values) in render (memoised). With uncontrolled fields: validate FormData at submit, or a field's value on blur, and store only the error map (which changes rarely). An effect that watches values and sets errors is an effect chain (part 11).
  2. Schemas: zod, valibot, yup, or a typed function; one object used by the client (field and form validation), the server action, and the API. Coercion (z.coerce.number()) handles the fact that form values are strings.
  3. The browser's own validation (required, type="email", pattern) is free, accessible, and localised; noValidate on the form turns off its popups if you render your own, while keeping :invalid and validity for styling and logic.
  4. Accessibility: aria-invalid on the field, aria-describedby pointing at the error element, role="alert" on errors that appear dynamically, a focused summary on failed submit.
46

Actions: useActionState, useFormStatus, and progressive enhancement

code
// a form with actions (React 19): progressive, pending-aware, and server-validated
'use server'
export async function transfer(prev, formData) {                       // (previousState, formData) → next state
  const parsed = schema.safeParse(Object.fromEntries(formData))        // validate on the server, always
  if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors, values: Object.fromEntries(formData) }
  const key = formData.get('idempotencyKey')
  const result = await bank.transfer({ ...parsed.data, key })          // idempotent by key
  revalidateTag('balance')
  redirect(`/receipts/${result.id}`)                                   // POST-redirect-GET: refresh is safe
}
// client
function TransferForm() {
  const [state, action, pending] = useActionState(transfer, { errors: {}, values: {} })
  const key = useRef(crypto.randomUUID())
  return (
    <form action={action}>
      <input type="hidden" name="idempotencyKey" value={key.current} />
      <label>Amount <input name="amount" inputMode="decimal" defaultValue={state.values.amount} aria-describedby="amount-err" /></label>
      {state.errors.amount && <p id="amount-err" role="alert">{state.errors.amount[0]}</p>}
      <SubmitButton />                                                  // useFormStatus().pending inside: disabled while submitting
    </form>
  )
}
function SubmitButton() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? 'Sending…' : 'Send'}</button> }
// without JavaScript: a normal POST to the action's endpoint; the server validates and redirects. with JavaScript: no reload, pending state, errors in place.
// the values survive a failed submit because the action returns them; the form is uncontrolled (defaultValue) so typing costs nothing.
the pieces (React 19)
  1. <form action={fn}>: React intercepts submission, builds FormData, calls fn(formData) inside a transition, and resets the form on success for uncontrolled fields. Without JavaScript (before hydration, or if it failed), a server action's form posts to the framework's endpoint as a normal form: progressive enhancement by construction.
  2. useActionState(action, initialState): wraps an action of the shape (previousState, formData) => nextState; returns [state, wrappedAction, isPending]. The returned state is where validation errors and echoed values live, so a failed submit re-renders the form with errors and keeps the user's input (via defaultValue from the returned values).
  3. useFormStatus(): inside a child of the form: { pending, data, method, action }. For a submit button that disables itself and a progress indicator, without threading state down.
  4. useOptimistic: part 8's optimistic layer, for forms whose result can be shown before the server answers (a comment appearing in the list while posting).
  5. Server actions ("use server"): the action runs on the server; validation happens there; redirect after success gives POST-redirect-GET for free; revalidateTag updates the caches (part 6).
what this changes about forms
  1. Uncontrolled becomes the default: FormData carries the values; React state holds only the action's returned state.
  2. Pending state is built in (the transition's isPending and useFormStatus) rather than a loading flag per form.
  3. Errors come from the one validation that matters (the server's), with client validation layered on for speed.
  4. The form works before hydration, which on slow connections is the difference between a submit that works and one that does nothing for two seconds.
47

Large forms

code
// a 60-field form that does not lag
// 1. uncontrolled by default: values in the DOM or in a form library's store; React renders a field only when ITS error or options change
const { register, handleSubmit, formState: { errors } } = useForm({ defaultValues })
<input {...register('amount', { required: true })} />              // a ref + native listeners; no React state per keystroke
// 2. subscribe narrowly: const currency = useWatch({ name: 'currency' })   // only this component renders when currency changes
// 3. split the form into sections, each its own component; errors are per section; a section renders on its own errors
// 4. dependent fields: a derived value from a watched field, not an effect that sets another field's value
// 5. virtualise repeated groups (a 500-row line-items table): the virtualisation chapter of the algorithms course; keep the data in the store, render the window
// 6. the submit: handleSubmit reads all values once; validate; one request
// what defeats it: a controlled <input value={state.x}> at the top-level form state (every keystroke renders everything); a context holding all values
// (every consumer renders); an effect per field syncing to a store (an effect chain per keystroke)
why large forms lag
  1. Top-level controlled state: const [values, setValues] = useState({...60 fields}) and value={values.x} everywhere: every keystroke re-renders the form component and all 60 inputs, their labels, their error elements. 60 × small is still large on a phone, and 60 × a date picker or a select with 200 options is a frozen field.
  2. All values in context: every consumer renders per keystroke (part 2).
  3. Effects syncing fields: "when currency changes, reformat the amount" as an effect: an extra render per change and a flash; derive the formatted amount instead.
  4. Validation of everything on every change: a 60-field schema parse per keystroke; validate the changed field on blur, the whole form on submit.
the structure that scales
  1. Values outside React (the DOM, or a form library's store) with per-field subscriptions: a keystroke renders nothing, or the one field whose error or derived display changed.
  2. Sections as components, each subscribing to its own fields and errors; a section renders when its own state changes.
  3. Dependent fields by derivation: useWatch('country') in the component that renders the state dropdown; the options are derived from the watched value in render.
  4. Repeated groups virtualised (line items, rows of a grid): the data in the store, the window rendered (the algorithms course part 1); field arrays with stable ids as keys.
  5. Submit reads once: all values at submit, one validation, one request, one pending state.
  6. The library choice: React Hook Form (uncontrolled, refs, the fastest for large forms), TanStack Form (framework-agnostic, typed), Conform (FormData-first, progressive), Formik (controlled; fine for small forms, slow at scale).
the sizes
A 60-field uncontrolled form costs nothing per keystroke and a few milliseconds at submit. The same form controlled at the top level costs a full render per keystroke: tens of milliseconds on a mid-range phone, which is visible lag while typing.
48

The money form

A form that moves money has every form problem plus the ones that cost money: a float where an integer was needed, a double submit, a retry that charged twice, a confirmation step that showed what was typed rather than what was parsed, a refresh that resubmitted. Each has a known defence; the form is the composition of all of them.

the defences
  1. Integer minor units. Parse the input to kobo or cents with integer arithmetic; store and send integers; format for display with Intl.NumberFormat. Never parseFloat for money; never store the display string as truth. (The JS course part 6: doubles cannot represent 0.1.)
  2. Controlled only where needed: the amount field (formatting) is controlled in its own small component; the rest uncontrolled.
  3. Validation against live data: the balance from a query (refetched on focus so it is not minutes stale); limits from config; the recipient resolved asynchronously with the result shown (name, bank) and cached.
  4. A review step that renders from parsed state: the formatted amount, the resolved recipient, fees, total. The user confirms the system's interpretation. "1500" versus "15000" is caught here or nowhere.
  5. Idempotency: a key per form instance (generated once, in a ref or hidden field), sent with the request; the server stores key → result and returns the stored result for duplicates. Double click, retry after timeout, and a flaky network all resolve to one transfer.
  6. Exactly-once UI: the submit disabled while pending (useFormStatus); the action's pending state visible; no optimistic update for the transfer itself (show the server's receipt, with the server's reference).
  7. After success: invalidate balance and history; navigate to a receipt route (POST-redirect-GET) so refresh is safe; reset the form by key so the old values and the old idempotency key are gone.
  8. After failure: keep the values and the key; say what happened and what to do (retry, check status, contact support); never say "failed" when the status is unknown (a timeout is unknown: check with the key before retrying or offer "check status").
  9. The flow as a machine: editing → reviewing → submitting → done | failed | unknown, with the legal transitions explicit. The number of edge cases is the argument for modelling it (part 7).
  10. Accessibility and locale: inputMode="decimal", locale-aware separators on input and display, labels and described-by errors, a focused error summary, and currency formatting that matches the user's locale rather than the developer's.
the pointer
Part 10 is the composition patterns: compound components, render props and hooks, state colocation, headless and polymorphic components, slots. The money form's sections, review step and field components are those patterns applied.
THE MONEY FORM
an amount field that cannot be wrong
swipe the figure sideways, or tap expand for full screen
1/6
the amount
Input: the user types "1,500.5". The field is controlled (it needs formatting). onChange strips to the allowed characters, parses to minor units with integer arithmetic (1,500.5 → "150050"), stores the integer in state, and displays a formatted string derived from it (Intl.NumberFormat). Never parseFloat; never store the display string as the truth.