Quiz - Forms the platform way
You’re building a bio field with a live 0/280 character counter that updates as the user types. A teammate makes the <textarea> fully controlled — value plus onChange — to drive the count. What’s the lighter move that keeps the field uncontrolled?
Keep defaultValue on the <textarea> (no value prop) and put useState only on the derived count, updating it from onChange. The handler reads the length to set a sibling readout but never writes back into the field, so the DOM still owns the text and it rides through FormData at submit.
The teammate is right — the moment any UI reacts to the field while typing, the input has to be controlled with value and onChange, counter included.
Drop the onChange entirely and read the length from FormData on submit; a live counter isn’t worth any client state.
The threshold for controlling an input is “does the field’s own rendered text have to be programmatically driven?” A counter doesn’t need that — it needs a sibling to react. Put state on the derived count, not the input: defaultValue stays, the onChange reads e.target.value.length to update the readout and never writes back, so the field is still uncontrolled and still round-trips through FormData. Making the whole field controlled is the over-correction the lesson warns against; dropping onChange loses the live counter the requirement asked for.
Given this submitted form, which FormData reads are correct? Select all that apply.
<input name="email" /> // typed "leo@acme.com"<input type="checkbox" name="subscribe" /> // left unchecked<input type="checkbox" name="addons" value="sms" /> // checked<input type="checkbox" name="addons" value="fax" /> // checkedformData.get('subscribe') is null — an unchecked box is absent from FormData entirely, not false or "off".
formData.getAll('addons') is ["sms", "fax"] — repeated names need getAll; get would return only the last value.
formData.get('subscribe') is false — an unchecked checkbox sends the boolean false under its name.
formData.get('addons') is ["sms", "fax"] — get returns every value sharing a name as an array.
Two non-obvious FormData shapes: an unchecked checkbox leaves no trace — get returns null, never false or "off" (a checked box would send "on"). And a repeated name is read with getAll, which returns the array; plain get quietly hands back only the last value, never an array. Both quirks are why the schema preprocesses checkboxes and why the action reaches for getAll on any field that can repeat.
Your invoice form needs to pass a fixed invoiceId to the action alongside the FormData, so you write <form action={() => updateInvoice(invoiceId, formData)}>. It works in every dev test, then breaks in production for some users. What happened, and what’s the right tool?
The arrow stops React from recognizing a form action, so it loses the build-time rewrite that powers the no-JavaScript POST fallback — the form breaks without JS while still passing every JS test. Use updateInvoice.bind(null, invoiceId) to pre-apply the id and keep a real function reference.
The arrow re-creates the function on every render, causing React to re-submit the form in a loop; memoize it with useCallback to fix the duplicate writes.
There’s no formData variable in scope for the arrow to reference, so it’s undefined at runtime everywhere; define const formData = new FormData() above the return.
Passing the bare function reference lets React own the lifecycle — it supplies the FormData and applies the rewrite that makes the form POST to the action’s URL without JavaScript. Wrap it in an arrow and React sees an ordinary function, not a form action: the progressive-enhancement fallback silently dies, so the form works with JS (every dev test) and breaks before hydration on a slow connection — the worst failure mode. The fix for an extra argument is action.bind(null, id), which yields a new reference React still treats as a form action.
A teammate wired a form with const [state, formAction, isPending] = useActionState(createInvoice, null) but put <form action={createInvoice}> on the element. At runtime the invoice still saves on every submit, yet the button never disables and field errors never appear. Which line is the bug?
<form action={createInvoice}> — it must be the bound formAction from the hook. The raw action runs and saves, but never reports back to the hook, so state and isPending stay frozen at their initial values.
useActionState(createInvoice, null) — the initial state can’t be null; seed it with ok(undefined) or the errors won’t render.
The action’s signature — once wrapped, the action must take (formData, prevState), not (prevState, formData).
The save works because the raw createInvoice runs fine — it just reports nothing back. Only the bound formAction routes the submit through the hook’s state machine, so with the raw action state stays null and isPending stays false forever. Swap to action={formAction}. null is the correct create-form initial state (every error read is gated on state?.ok === false), and the wrapped signature is (prevState, formData) — prevState first.
You extract a SubmitButton so a nested button can read the form’s submit state without prop-drilling. It renders fine but the spinner never shows and the button never disables — no error, no warning. What’s the most likely cause?
useFormStatus() is being called in the form’s own component scope rather than from a descendant rendered inside the <form>. The form broadcasts its state to the subtree below it, so from the owner’s seat pending is false forever.
useFormStatus was imported from react instead of react-dom, which silently returns a stub that’s always false.
The form is missing a method="post", so useFormStatus can’t tell it’s submitting and defaults pending to false.
useFormStatus reads the context a <form> broadcasts to its descendants. Called in the form’s own render scope, the component renders the form but doesn’t live inside it, so pending is false forever — a silent, afternoon-eating bug. Move the hook into a child rendered inside the <form> (the SubmitButton) and it flips correctly. The wrong import (it does come from react-dom) would actually fail the build, not silently no-op, and method is irrelevant to whether the broadcast reaches a descendant.
For which of these mutations is useOptimistic the right reach, rather than a plain pending state? Select all that apply.
A “star/unstar” toggle on an invoice the user is looking at.
A “mark notification as read” tap in a visible list.
A payment submission on a checkout form.
A validation-heavy “create invoice” form with uniqueness and plan-limit checks.
Optimism earns its weight only when all three hold: high success rate, visible to the user, and a small UI change. A star toggle and a “mark as read” clear all three — they almost always succeed, the user’s eyes are on them, and the change is one boolean. Payments fail the high-stakes test (the user must see the real confirmation — never optimistic), and a validation-heavy create form fails the success-rate test (a rollback after the user watched their data appear reads as a bug). Those two stay on pending state.
You add useOptimistic to a non-form “star” button. The star flips the instant you click, then immediately snaps back to its old value before the server even responds, and the console logs a warning about an update “outside a Transition or Action.” What did you forget?
The addOptimistic call must run inside a transition. For an imperative onClick there’s no action prop to open one automatically, so you wrap the update and the await in startTransition yourself.
The reducer must return a brand-new array reference; returning the same boolean makes React discard the update after one frame.
The optimistic value needs a client-generated UUID so React can reconcile it by key; without an id the overlay can’t persist.
Optimistic state lives only inside a transition’s lifecycle — with no transition open, the value renders for a single frame and reverts. A <form action> (or <button formAction>) opens one for you; an imperative onClick does not, so you supply it with startTransition, firing addOptimistic and awaiting the action inside it. Forms automatic, imperative handlers manual — that’s the trap. The UUID matters for reconciling list items by key, not for whether a toggle’s overlay sticks.
Sort each invoice rule into where it must be enforced: a constraint attribute mirrored by the schema, schema-only, or the action body only. Which mapping is correct?
“Email is valid” → constraint attribute + schema (mirror); “amount has at most two decimals” → schema only; “invoice number is unique in the org” → action body only.
“Email is valid” → constraint attribute only; “amount has at most two decimals” → constraint attribute + schema; “invoice number is unique in the org” → schema via an async .refine.
All three are mirrors — every rule belongs in both a constraint attribute and the schema, since the schema is the single source of truth.
Shape rules the browser can express (presence, format, range) are mirrors — the attribute for instant UX, the schema for correctness, since a stale tab or a curl skips the attributes. “At most two decimals” looks mirrorable, but step="0.01" is only a nudge a script ignores; a real two-decimal guarantee is schema-only (.multipleOf(0.01)). Uniqueness needs server state the browser can’t see — it’s a wall, enforceable only in the action body after the parse. The browser is the cheap layer; the server is the boundary of correctness.
You want a required email field to show a red border, but only after the user has actually engaged it — not the instant the page loads. Which CSS state do you key the styling off?
:user-invalid (Tailwind’s user-invalid:) — it matches only after the user has edited and left the field or tried to submit.
:invalid (Tailwind’s invalid:) — it’s the standard validity selector and the most direct way to flag a bad field.
:invalid matches a field’s validity right now, from first paint — so a required field paints red before the user types a character, scolding them for a form they just opened. :user-invalid matches only after interaction, which is the experience you want. Pair it with aria-invalid: to give the same red border to server-returned errors. Reaching for :invalid here is the single most common bug at this API.
A user on slow Wi-Fi submits your <form action={createInvoice}> before the JS bundle finishes hydrating. Which statements about what happens are true? Select all that apply.
The submit still works: the browser issues a native POST to the action’s build-registered URL, and createInvoice runs and redirects on success.
The browser’s constraint checks (required, type="email", pattern) still fire — they’re the one validation layer that survives without JavaScript.
The useActionState inline field errors and the useFormStatus spinner still render, since the action returns the same Result either way.
Nothing happens until hydration completes, because the action prop needs React to intercept the submit.
The rule: everything the platform provides survives, everything the React runtime provides degrades. The native-browser door issues a real POST to the opaque action ID’s registered endpoint, so the action runs and its redirect() carries the success experience; constraint validation runs in the browser regardless of the bundle. What degrades is the in-place rendering — useActionState’s inline errors and useFormStatus’s spinner need React to re-render, so they don’t appear (the action still produces the Result; there’s just nowhere to render it). This is why every user benefits during the pre-hydration window, not just the JS-disabled minority.
Quiz complete
Score by topic