Forms: controlled, uncontrolled and validated
Every form question reduces to one thing: does React own the value, or does the DOM? Controlled means state is the source of truth and the input just displays it; uncontrolled means the DOM node holds the value and you go and read it when you need it. Pick a side per input and never straddle - the single most common React warning in existence is what straddling looks like. The second half of the topic is validation timing, and the answer nobody expects you to have is touched: errors that appear before the user has typed anything are the reason forms feel hostile.
Who owns the value
A controlled input has both value and onChange. React renders the value from state, the DOM tells you a keystroke happened, you set state, React re-renders with the new value. The input can only ever show what state says - which is why formatting, masking, disabling and cross-field validation all become trivial, and why every keystroke is a render.
An uncontrolled input has defaultValue (or defaultChecked) and a ref, or nothing at all. The DOM holds the value; React sets the initial one and then stops caring. You read ref.current.value at submit time, or read the whole form at once with FormData.
Default to controlled. Reach for uncontrolled when the DOM genuinely owns the value and cannot hand it over - a file input - when you are wrapping a non-React widget that mutates its own node, or when a large form is re-rendering on every keystroke and you have measured that it matters.
- `value` + `onChange`
- Controlled. Omit the
onChangeand React logs a warning and makes the field read-only, because you have told it the value can never change. - `defaultValue`
- Uncontrolled. It seeds the DOM on the first render and is ignored on every render after - changing it later does nothing, which is the bug people hit when they try to 'reset' a form by changing
defaultValue. - `value={undefined}`
- Neither, and then both. React sees no value on the first render so it treats the input as uncontrolled; when the data arrives and the value becomes a string, it switches - and warns. Initialise every field to
"", neverundefinedornull.
When to show the error
Validate on submit, always - that is the one that has to be right. Then improve it: once a field has been blurred, validate it on every change so the error clears as the user fixes it. That combination is what a good form feels like, and it is three lines of state.
The state is three objects with the same keys: values, errors, touched. errors is derived - a pure validate(values) function returning a map of field to message - and touched is what decides whether an error is allowed to be visible yet. Showing errors.email gated on touched.email is the difference between a form that helps and a form that shouts at an empty page.
On submit, mark everything touched at once, run the validator, and bail if it returned anything. That way the submit attempt reveals every problem in the form, which is what the user asked for by pressing the button.
Questions
Answer each one yourself before you open it.
01Controlled or uncontrolled - define both.
Controlled: the input's value comes from React state and an
onChangewrites every keystroke back to state, so state is the single source of truth. Uncontrolled: the DOM node holds the value, you seed it withdefaultValue, and you read it out of a ref when you need it.The word that matters is 'source of truth'. In a controlled input there is exactly one place the value can be, and the rendered field is a projection of it. In an uncontrolled one React has genuinely handed the value to the browser and has no idea what it is between renders.
02Which do you default to, and why?
Controlled. Anything that reacts to the value as it is typed - a disabled submit, a character counter, live validation, one field depending on another, formatting as you type - is free when state already holds the value, and awkward otherwise.
The cost is a render per keystroke, which for a normal form is nothing: React is re-rendering one small component with one changed string. Trading that for the ability to always know what the form contains is the right trade until a profiler says otherwise.
Follow-up So when is controlled the wrong call?
A very large form where every keystroke re-renders hundreds of nodes, and any case where the DOM owns the value by definition. That is when you go uncontrolled or reach for react-hook-form, which is uncontrolled underneath for exactly this reason.
03Name a case where uncontrolled is the only option.
A file input.
<input type="file">is always uncontrolled - you cannot set its value from JavaScript, for security reasons, so React can only read it. The same is true when you integrate a non-React widget, like a date picker or a rich text editor, that owns and mutates its own DOM node.Being able to set a file input's value programmatically would let a page silently attach a file from the user's disk. The browser forbids it, so
valueis meaningless and the only path is a ref plusonChangereadinge.target.files.04How do you handle ten fields without ten
useStatecalls?One state object and one generic handler keyed by the input's
name:setValues(v => ({ ...v, [e.target.name]: e.target.value })). Give every input anamethat matches a key, and onehandleChangeserves the whole form.The computed key
[e.target.name]is what makes it generic - it evaluates the expression and uses the result as the property name. The spread copies the other fields, becauseuseStatereplaces rather than merges. Miss the spread and typing in one field empties every other one.Follow-up And a checkbox in that same handler?
Branch on the type:
const value = e.target.type === "checkbox" ? e.target.checked : e.target.value;. A checkbox'svalueis the string"on"unless you set one, so readingvaluefor a checkbox is always wrong.05Why does the submit handler call
preventDefault?Because a
<form>submitting natively navigates the page - a full reload - and everything React was holding is gone.e.preventDefault()stops that so you can submit with fetch instead.It is worth still using a real
<form>with anonSubmitrather than a bare button with anonClick: you get Enter-to-submit, the browser's own required-field semantics if you want them, and the correct role for assistive technology. You just intercept the navigation.Follow-up Does the submit button need
type="submit"?It is the default inside a form, but any button without an explicit type is a submit button - which is why a stray
<button>Cancel</button>inside a form reloads the page. Puttype="button"on anything that is not the submit.06When do you run validation?
On submit for certain, and on change for any field that has already been blurred. So the user is never told they are wrong before they have finished, and the moment they fix it the message disappears.
Validating on every change from the start means an empty required field is an error before the user has touched it. Validating only on submit means they fix one field, press submit, and find another. The blurred-then-live combination is what people mean by a form that feels good, and it is what
touchedexists to express.07What shape is the
errorsobject?A plain map of field name to message, with a key present only when that field is invalid:
{ email: 'Enter a valid email' }. Valid means the key is absent, soObject.keys(errors).length === 0is the whole 'is the form valid' test.Keep it derived from a pure
validate(values)function rather than assembled piecemeal in handlers. One function, one place to read what the rules are, testable without rendering anything - and it lets submit and blur share exactly the same logic.Follow-up Why not a boolean
isValidin state?Because then there are two sources of truth that can disagree, and the one you rendered from is the one that will be stale.
08What is
touchedfor?A map of which fields the user has blurred, so an error is only rendered when both
errors.emailandtouched.emailare set. Without it a freshly opened form shows every error immediately.On submit you mark all of them touched in one go - build the map from
Object.keys(values)- so pressing submit reveals every outstanding problem at once. That is the behaviour people expect and it is two lines.09Should the submit button be disabled while the form is invalid?
Disable it while SUBMITTING, not while invalid. A permanently disabled button with no explanation is a dead end - the user cannot press it and is not told why. Let them press it and show them the errors.
It is also an accessibility problem: a disabled button is skipped by keyboard navigation and announces nothing, so a screen reader user gets silence instead of a reason. Disabling during an in-flight submit is different - it prevents a double submit, and the reason is visible because the button says 'Saving…'.
10Walk me through an async submit.
preventDefault, mark everything touched, validate and bail if there are errors, set
submittingtrue, await the request in a try/catch/finally, put a server failure into a separatesubmitError, reset the form on success, and clearsubmittingin thefinally.The
finallymatters: without it a thrown request leaves the button disabled forever and the user is stuck on a form that will not resubmit. And the server error goes in its own piece of state rather than into the field-levelerrorsmap, because it is not about a field - unless the server sends back per-field messages, in which case you merge them intoerrors.Follow-up How do you stop a double submit?
The
submittingflag both disables the button and guards the top of the handler. The guard is what actually stops it - a fast double press can beat the re-render.11How do you reset the form after a successful submit?
Set state back to the initial values object, and clear
errorsandtouched. Keep the initial object as a constant outside the component so nothing can mutate it and the reset is one assignment.For an uncontrolled form the equivalent is
formRef.current.reset(), which is the DOM's own method. What does not work in either case is changingdefaultValue- that is only read on the first render.12What is the gotcha with checkboxes?
They use
checked, notvalue, and the thing that changed ise.target.checked. A checkbox'svalueattribute defaults to the string"on"and has nothing to do with whether it is ticked.This is what breaks the generic handler:
[e.target.name]: e.target.valueputs the string"on"in state for every checkbox, forever, whether ticked or not - and"on"is truthy, so the bug survives everyifyou write against it.13How do you handle a group of checkboxes producing an array?
Keep an array in state and add or remove on change:
setTags(t => e.target.checked ? [...t, e.target.value] : t.filter(x => x !== e.target.value)). Each box getschecked={tags.includes(tag)}and a realvalue.This is the one case where a checkbox's
valuematters - you are using it to identify which box it is.checkedstill answers 'is it on',valueanswers 'which one', and you need both.14What is the gotcha with radio buttons?
They share one
nameand one piece of state. Each input gets its ownvalue, andchecked={choice === thisValue}- the state holds the selected value, not a boolean per button.The shared
nameis what makes the browser treat them as one group and give you native arrow-key navigation. People often reach for a boolean per radio, which then lets two be on at once in React state while the DOM shows only one.15How does a
<select>differ from plain HTML in React?The
valuegoes on the<select>itself, notselectedon an<option>. React readsvalueand marks the matching option.It is one of the few places React deliberately diverges from HTML, and it exists so a select is controlled the same way every other input is - one
value, oneonChange. Puttingselectedon an option gets you a warning telling you to usevalueon the select.Follow-up And a multi-select?
valuebecomes an array, and to read it you needArray.from(e.target.selectedOptions, o => o.value)-e.target.valueon a multi-select gives you only the first selected option, which is a silent wrong answer rather than an error.16How do you render a textarea in React?
With a
valueprop, like every other input:<textarea value={bio} onChange={...} />. Not as children - HTML puts the text between the tags, React does not.Same motivation as the select divergence: one uniform controlled-input contract. Children on a textarea gets you a warning telling you to use
defaultValueorvalue.17"A component is changing an uncontrolled input to be controlled" - what causes it?
The input's
valuewasundefinedornullon an early render and became a string later. React decided it was uncontrolled the first time it saw it, and the switch is what it is warning about.The two usual sources are a state object that does not have a key for that field yet, and data arriving asynchronously -
value={user.name}whileuseris stillnull. Initialise every field to""in the initial state object, or writevalue={user?.name ?? ""}. The reverse warning, controlled to uncontrolled, is the same bug in the other direction: someone set a value back toundefined.Follow-up Why does React care?
Because the two modes have different behaviour and it cannot switch cleanly - an uncontrolled input has whatever the user typed, and the moment it becomes controlled that content is replaced by state, which the user experiences as their typing being deleted.
18
defaultValuevsvalue?valueis controlled and applies on every render.defaultValueseeds the DOM on the first render only and is ignored afterwards. Using both on one input is a bug - React ignores thedefaultValueand warns.The consequence people trip on: you cannot clear an uncontrolled input by setting
defaultValueto"", because nothing re-reads it. You callform.reset(), setref.current.value = ""directly, or change the input'skeyto force a fresh node.19How do you build an uncontrolled form?
A ref per input plus
defaultValue, then readnameRef.current.valuein the submit handler. Or one ref on the<form>andObject.fromEntries(new FormData(formRef.current)), which needs anameon every input and no refs at all.The FormData version is the one worth knowing: it is shorter, it scales to any number of fields without touching the component, and it is what a plain HTML form would have sent. Its limitation is that unticked checkboxes are simply absent from the result rather than present and false.
20How do you read the file a user picked?
e.target.files- aFileList, soe.target.files[0]for a single file andArray.from(e.target.files)for multiple. Keep theFileobject in state if you need a preview, and append it to aFormDatato upload it.The input itself stays uncontrolled; what you can control is your own state holding the selected file. For a preview,
URL.createObjectURL(file)gives you a src - and it needs aURL.revokeObjectURLin an effect cleanup or you leak the blob.Follow-up How do you clear it?
ref.current.value = "". It is the one place assigning to a DOM node directly is the normal answer.21A number input gives you what type?
A string, always.
e.target.valueon<input type="number">is"42", and it is""when the field is empty - soNumber(value)on an empty field gives you 0, not NaN.That 0 is the trap: a required-quantity validation written as
if (!Number(qty))accidentally accepts an empty field as a zero, andNumber("") === 0is why. Validate the raw string first, then convert.valueAsNumberis the other option and givesNaNfor empty, which fails more usefully.22Can you debounce a controlled input?
Not the input itself - it must re-render on every keystroke or typing lags and the cursor jumps. Debounce the CONSUMER: keep
valuebound to state, and pass a debounced copy of it to the search or the validation.This is the question behind
useDebounce. Two values, one fast and one slow: the fast one is what the user sees, the slow one is what does the expensive work.23Is a controlled form slow?
Only if a keystroke re-renders something big. One input re-rendering its own component is nothing; a hundred-field form where every field lives in one component's state re-renders all hundred on every character.
The fixes in order of effort: move each field into its own component so state is local, memoise the expensive siblings, or go uncontrolled. Reaching for the third before measuring is how you end up with a form you cannot validate live.
24When would you reach for react-hook-form?
When the form is big, the validation is non-trivial, or you want a schema. It keeps inputs uncontrolled via
register, so typing does not re-render the form, and it gives youerrors,touchedFields,isSubmittingand a resolver for Zod or Yup without you writing any of it.Everything on this page is what the library is doing for you - it is worth being able to say that. The judgement answer is that a login form with two fields does not need it, and a checkout with conditional sections, per-field async validation and a field array does.
Follow-up Why is it faster?
Because uncontrolled inputs do not re-render the form on each keystroke; it subscribes to what it needs and only re-renders the subtrees that display errors.
25How do you handle a dynamic list of fields - add and remove a row?
Keep an array of objects in state, each with a stable id generated when the row is created. Render with
key={row.id}, and add or remove by producing a new array.The
keymust be the row's own id, not the array index. With an index key, deleting the middle row makes React reuse the wrong DOM nodes and the values shift up a row - text that belonged to row three appears in row two, because the index says they are the same element.26How do you validate a field that depends on another one?
In the shared
validate(values)function, which sees the whole object - password confirmation isif (values.confirm !== values.password). That is the payoff of validating from one pure function over all values rather than per input.It also means changing the password field re-evaluates the confirm error automatically, since the whole map is recomputed. Per-field validators cannot do that without wiring the fields to each other.
27How do you make form errors accessible?
Every input gets a real
<label htmlFor>, an invalid one getsaria-invalid="true"andaria-describedbypointing at its message element, and the message container is a live region so it is announced when it appears.A red border is invisible to a screen reader and to a colourblind user; the message has to be text, associated with the field, and announced. On submit, moving focus to the first invalid field is the other half - otherwise the user is left at the bottom of the form with an error they cannot find.
Follow-up How do you focus the first invalid field?
Keep a ref per field, or query the form for the first
[aria-invalid="true"]after validation fails and callfocus()on it.28Where does server-side validation fit?
Underneath all of it, always. Client validation is for the user's benefit - instant, specific feedback. The server validates because anything reaching it can be forged, and a curl request never ran your React.
The practical consequence for the form: the submit handler has to be able to render errors that came back from the server, which means the
errorsmap has to accept an update from a response as well as fromvalidate().29How do you keep two forms in sync - an edit form seeded from fetched data?
Don't sync it with an effect. Give the form component a
keyof the record's id so a different record mounts a fresh form, and pass the fetched values as the initial state.The effect version - 'when
userchanges, set every field' - fights the user: a response arriving while they are typing overwrites what they typed. The key approach makes 'a different record' a remount and 'the same record' a no-op, which is the actual rule you wanted.30What does
onChangemean in React versus in HTML?React's
onChangefires on every keystroke - it is the DOM'sinputevent under the hood. HTML's nativechangefires only on blur or commit. So React's is the one you want and it is deliberately misnamed.It matters when you are integrating with a non-React widget or reading the DOM directly, and it is a good detail to have ready: React normalises the event across browsers into a synthetic event, and its
onChangeisinput, notchange.31What is a synthetic event?
React's cross-browser wrapper around the native event, with the same API. Since React 17 the listeners are attached at the root container of your app, not at the document, and since React 18 the objects are no longer pooled.
The pooling detail is worth knowing because old code has
e.persist()in it: React used to reuse the event object, so readinge.target.valueinside an async callback after an await gave you null. That is gone in React 17+, but the safe habit - read the value into a local first - is still what stops the question.32How do you build a masked or formatted input - a phone number?
Controlled, and format inside the change handler: strip everything non-numeric, reformat, set the formatted string as state. Keep the raw digits separately if the server wants them unformatted.
This is only possible because the value round-trips through state - the input can only display what you give back, so you can give back something different from what was typed. It is the clearest argument for controlled inputs, and the caret position is the thing that will bite you: naive reformatting sends the cursor to the end mid-edit.
Predict the output
The output is hidden. Predict it before you open it.
What type does a number input give you?
<input
type="number"
onChange={(e) =>
console.log(typeof e.target.value, JSON.stringify(e.target.value), e.target.valueAsNumber)
}
/>
// the user types 4, then clears the fieldShow the output
string "4" 4
string "" NaNe.target.value is always a string, including the empty string when the field is cleared. valueAsNumber is the numeric view and gives NaN for empty, which is the more useful failure - because Number("") is 0.
The empty-string-to-zero trap
console.log(Number(""), Number(" "), Number("4"));
console.log(parseInt("", 10));
console.log(Boolean(" "), " ".trim().length);Show the output
0 0 4
NaN
true 0A required-quantity check written as if (!Number(qty)) treats an empty field as the number zero, and a required-name check written as if (!name) accepts three spaces. Validate the raw string - value.trim() === "" - before you convert anything.
The computed key, with and without the brackets
const values = { name: "sample", email: "" };
const name = "email";
console.log({ ...values, [name]: "a@b.c" });
console.log({ ...values, name: "a@b.c" });Show the output
{ name: 'sample', email: 'a@b.c' }
{ name: 'a@b.c', email: '' }Brackets evaluate the expression and use the result as the key. Without them the literal string name is the key - so a generic handler missing its brackets writes every field into a property called name and nothing else ever updates.
What a checkbox reports
<input
type="checkbox"
name="terms"
onChange={(e) => console.log(e.target.name, e.target.value, e.target.checked)}
/>
// ticked, then untickedShow the output
terms on true
terms on falseA checkbox's value defaults to the string "on" and never changes. Only checked carries the information. Set a real value when the box is one of a group and you need to know which box it was.
The generic handler meeting a checkbox
function reduce(values, e) {
return { ...values, [e.target.name]: e.target.value };
}
// checkbox name="terms": ticked, then unticked
let state = {};
state = reduce(state, tickEvent);
console.log(state);
state = reduce(state, untickEvent);
console.log(state);Show the output
{ terms: 'on' }
{ terms: 'on' }Unticking changes nothing, and "on" is truthy, so a terms-accepted check passes forever. The fix is one line in the handler: const value = e.target.type === "checkbox" ? e.target.checked : e.target.value.
Reading a multi-select
<select
multiple
onChange={(e) => {
console.log(e.target.value);
console.log(Array.from(e.target.selectedOptions, (o) => o.value));
}}
>
<option value="a">A</option>
<option value="b">B</option>
<option value="c">C</option>
</select>
// A and C are selectedShow the output
a
['a', 'c']e.target.value on a multi-select returns only the first selected option - no error, just a quietly wrong answer. selectedOptions is the collection you actually want, and the controlled value prop for a multi-select is an array.
Is the form valid?
const errors = {};
console.log(Object.keys(errors).length === 0);
if (errors) console.log("this branch always runs");
console.log(Object.keys({ email: "Required" }).length === 0);Show the output
true
this branch always runs
falseAn empty object is truthy, so if (errors) is not a validity check - it is always true. Count the keys. This is the single most common way a form submits while invalid.
The warning everyone has seen
function NameField({ user }) {
return <input value={user.name} onChange={() => {}} />;
}
// first render: user = {}
// after the fetch resolves: user = { name: "sample" }Show the output
Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen.On the first render value is undefined, so React treats the input as uncontrolled. When the data lands it becomes a string and React has to switch modes mid-life. Fix it at the source: value={user.name ?? ""}, or initialise every field in the state object to an empty string.
A value with no way to change it
<input value="hello" />Show the output
Warning: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`.React renders exactly what state says, and you gave it a constant - so typing does nothing. The warning names all three legitimate intentions: controlled (add onChange), uncontrolled (use defaultValue), or genuinely read-only (say readOnly).
defaultValue after the first render
function Draft() {
const [n, setN] = useState(0);
const ref = useRef(null);
return (
<div>
<input ref={ref} defaultValue={"draft " + n} />
<button
onClick={() => {
setN(n + 1);
console.log(ref.current.value);
}}
>
bump
</button>
</div>
);
}
// the user types "hello" over the default, then clicks bump twiceShow the output
hello
hellodefaultValue seeds the DOM node once. Re-rendering with a different one changes nothing, because the node already exists and owns its value. To reset an uncontrolled input you call form.reset(), assign ref.current.value directly, or change the input's key to force a new node.
FormData and the missing checkbox
// <form>
// <input name="email" defaultValue="a@b.c" />
// <input type="checkbox" name="terms" /> <-- left unticked
// </form>
const data = Object.fromEntries(new FormData(formRef.current));
console.log(data);
console.log(data.terms === false);Show the output
{ email: 'a@b.c' }
falseAn unticked checkbox is not submitted at all, so the key is absent rather than false - and undefined === false is false, so a naive check passes. Read booleans off the element (form.elements.terms.checked) or merge the FormData over a defaults object.
Forgetting preventDefault
function handleSubmit(e) {
console.log("submitting", { email: "a@b.c" });
}
// <form onSubmit={handleSubmit}>
// <input name="email" defaultValue="a@b.c" />
// <button>Send</button>
// </form>Show the output
submitting { email: 'a@b.c' }
(then the browser navigates to ?email=a%40b.c and the console clears)The handler runs, and then the default action does too: a native form submit is a full page navigation. Everything React was holding is gone, which is why the symptom is usually reported as "my state resets when I submit".
What a radio's onChange reports
<input
type="radio"
name="pay"
value="cash"
checked={choice === "cash"}
onChange={(e) => console.log(e.target.value, e.target.checked)}
/>
// the user selects itShow the output
cash trueA radio's change event only fires when it becomes selected, so checked is always true and carries nothing. The information is value, which is why radio state is the selected value rather than a boolean per button.
Write it from memory
Complete, runnable code. Practice typing it from memory before your interview.
A complete controlled form - validation, touched, async submit
The one to type from memory. Every piece an interviewer looks for is in it: one state object with a generic handler, a pure validator, a touched map gating the messages, all fields marked touched on submit, an in-flight flag with a finally, a server error kept separate from the field errors, and labels wired for accessibility.
import { useState } from "react";
const INITIAL = {
name: "",
email: "",
password: "",
plan: "free",
terms: false,
};
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validate(values) {
const errors = {};
if (!values.name.trim()) errors.name = "Name is required";
if (!values.email.trim()) errors.email = "Email is required";
else if (!EMAIL.test(values.email)) errors.email = "Enter a valid email";
if (values.password.length < 8) errors.password = "Use at least 8 characters";
if (!values.terms) errors.terms = "You have to accept the terms";
return errors;
}
export default function SignUpForm() {
const [values, setValues] = useState(INITIAL);
const [touched, setTouched] = useState({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const [done, setDone] = useState(false);
const errors = validate(values);
const isValid = Object.keys(errors).length === 0;
const showError = (field) => (touched[field] ? errors[field] : undefined);
function handleChange(e) {
const { name, type } = e.target;
const value = type === "checkbox" ? e.target.checked : e.target.value;
setValues((v) => ({ ...v, [name]: value }));
}
function handleBlur(e) {
const { name } = e.target;
setTouched((t) => ({ ...t, [name]: true }));
}
async function handleSubmit(e) {
e.preventDefault();
if (submitting) return;
const all = {};
for (const key of Object.keys(INITIAL)) all[key] = true;
setTouched(all);
if (!isValid) return;
setSubmitting(true);
setSubmitError(null);
try {
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error("Could not sign up (" + res.status + ")");
setValues(INITIAL);
setTouched({});
setDone(true);
} catch (err) {
setSubmitError(err.message);
} finally {
setSubmitting(false);
}
}
if (done) return <p role="status">Account created.</p>;
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={Boolean(showError("name"))}
aria-describedby={showError("name") ? "name-error" : undefined}
/>
{showError("name") && (
<p id="name-error" role="alert">
{errors.name}
</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={Boolean(showError("email"))}
aria-describedby={showError("email") ? "email-error" : undefined}
/>
{showError("email") && (
<p id="email-error" role="alert">
{errors.email}
</p>
)}
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={values.password}
onChange={handleChange}
onBlur={handleBlur}
aria-invalid={Boolean(showError("password"))}
aria-describedby={showError("password") ? "password-error" : undefined}
/>
{showError("password") && (
<p id="password-error" role="alert">
{errors.password}
</p>
)}
</div>
<div>
<label htmlFor="plan">Plan</label>
<select
id="plan"
name="plan"
value={values.plan}
onChange={handleChange}
onBlur={handleBlur}
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
</div>
<div>
<label htmlFor="terms">
<input
id="terms"
name="terms"
type="checkbox"
checked={values.terms}
onChange={handleChange}
onBlur={handleBlur}
/>
I accept the terms
</label>
{showError("terms") && <p role="alert">{errors.terms}</p>}
</div>
{submitError && <p role="alert">{submitError}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Create account"}
</button>
</form>
);
}errorsis computed during render from a pure function - not stored in state. One source of truth, and cross-field rules like password confirmation come for free because the validator sees every value.- The submit marks every field touched BEFORE bailing, so one press reveals every outstanding problem rather than one at a time.
- The submit button is disabled only while submitting, never while invalid: a permanently dead button with no explanation is worse than letting the user press it and showing them why.
if (submitting) returnat the top of the handler is the real double-submit guard - a fast second press can beat the re-render that disables the button.noValidateturns off the browser's own bubbles so yours are the only ones, and thetypeattributes still give mobile the right keyboard.- The checkbox reads
e.target.checked, and its state is a real boolean. Readingvaluethere would store the string"on"forever.
The same form uncontrolled - refs and FormData
The comparison an interviewer asks for right after the controlled one. No state per keystroke, no re-renders while typing, and the values are read once at submit. Note what you lose: nothing can validate live, and nothing can react to a value until submit.
import { useRef, useState } from "react";
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validate(values) {
const errors = {};
if (!values.name.trim()) errors.name = "Name is required";
if (!values.email.trim()) errors.email = "Email is required";
else if (!EMAIL.test(values.email)) errors.email = "Enter a valid email";
if (values.password.length < 8) errors.password = "Use at least 8 characters";
if (!values.terms) errors.terms = "You have to accept the terms";
return errors;
}
export default function SignUpFormUncontrolled() {
const formRef = useRef(null);
const nameRef = useRef(null);
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
async function handleSubmit(e) {
e.preventDefault();
if (submitting) return;
const form = e.currentTarget;
const raw = Object.fromEntries(new FormData(form));
const values = {
name: raw.name ?? "",
email: raw.email ?? "",
password: raw.password ?? "",
plan: raw.plan ?? "free",
terms: form.elements.terms.checked,
};
const found = validate(values);
setErrors(found);
if (Object.keys(found).length > 0) {
if (found.name) nameRef.current.focus();
return;
}
setSubmitting(true);
setSubmitError(null);
try {
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error("Could not sign up (" + res.status + ")");
form.reset();
setErrors({});
} catch (err) {
setSubmitError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<form ref={formRef} onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" ref={nameRef} defaultValue="" />
{errors.name && <p role="alert">{errors.name}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" defaultValue="" />
{errors.email && <p role="alert">{errors.email}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" defaultValue="" />
{errors.password && <p role="alert">{errors.password}</p>}
</div>
<div>
<label htmlFor="plan">Plan</label>
<select id="plan" name="plan" defaultValue="free">
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
</div>
<div>
<label htmlFor="terms">
<input id="terms" name="terms" type="checkbox" defaultChecked={false} />
I accept the terms
</label>
{errors.terms && <p role="alert">{errors.terms}</p>}
</div>
{submitError && <p role="alert">{submitError}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Create account"}
</button>
<button type="button" onClick={() => formRef.current.reset()}>
Reset
</button>
</form>
);
}FormDataneeds anameon every input, and gives you strings. An unticked checkbox is absent from it entirely, which is whytermsis read offform.elementsinstead.form.reset()is the DOM's own reset and is the only thing that clears uncontrolled inputs - re-rendering with a differentdefaultValuedoes nothing.- The named ref exists for one job: moving focus to the first invalid field. Without it the user is left staring at a message they may not have scrolled to.
type="button"on the reset button is essential. A button in a form with no type is a submit button.- What you gave up: no character counter, no live password strength, no disabling anything based on what is typed, and no validation until submit. That is the trade, and saying it out loud is the answer to "which would you use here?".
useForm - the reusable hook
Everything from the controlled build, extracted. This is roughly what react-hook-form gives you, minus the uncontrolled performance trick - worth building once so you can say exactly what the library is doing.
import { useState, useCallback } from "react";
export function useForm({ initialValues, validate, onSubmit }) {
const [values, setValues] = useState(initialValues);
const [touched, setTouched] = useState({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const errors = validate ? validate(values) : {};
const isValid = Object.keys(errors).length === 0;
const handleChange = useCallback((e) => {
const { name, type } = e.target;
const value = type === "checkbox" ? e.target.checked : e.target.value;
setValues((v) => ({ ...v, [name]: value }));
}, []);
const handleBlur = useCallback((e) => {
const { name } = e.target;
setTouched((t) => ({ ...t, [name]: true }));
}, []);
const reset = useCallback(() => {
setValues(initialValues);
setTouched({});
setSubmitError(null);
}, [initialValues]);
async function handleSubmit(e) {
e.preventDefault();
if (submitting) return;
const all = {};
for (const key of Object.keys(initialValues)) all[key] = true;
setTouched(all);
if (!isValid) return;
setSubmitting(true);
setSubmitError(null);
try {
await onSubmit(values);
reset();
} catch (err) {
setSubmitError(err.message);
} finally {
setSubmitting(false);
}
}
function fieldProps(name) {
const invalid = Boolean(touched[name] && errors[name]);
return {
name,
id: name,
value: values[name] ?? "",
onChange: handleChange,
onBlur: handleBlur,
"aria-invalid": invalid,
};
}
function checkboxProps(name) {
return {
name,
id: name,
type: "checkbox",
checked: Boolean(values[name]),
onChange: handleChange,
onBlur: handleBlur,
};
}
const errorFor = (name) => (touched[name] ? errors[name] : undefined);
return {
values,
errors,
touched,
submitting,
submitError,
isValid,
handleSubmit,
fieldProps,
checkboxProps,
errorFor,
reset,
setValues,
};
}
// Using it - the whole login form is now nine lines of JSX
const INITIAL = { email: "", password: "" };
function validateLogin(values) {
const errors = {};
if (!values.email.trim()) errors.email = "Email is required";
if (!values.password) errors.password = "Password is required";
return errors;
}
export function LoginForm() {
const form = useForm({
initialValues: INITIAL,
validate: validateLogin,
onSubmit: async (values) => {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!res.ok) throw new Error("Wrong email or password");
},
});
return (
<form onSubmit={form.handleSubmit} noValidate>
<label htmlFor="email">Email</label>
<input {...form.fieldProps("email")} type="email" />
{form.errorFor("email") && <p role="alert">{form.errorFor("email")}</p>}
<label htmlFor="password">Password</label>
<input {...form.fieldProps("password")} type="password" />
{form.errorFor("password") && (
<p role="alert">{form.errorFor("password")}</p>
)}
{form.submitError && <p role="alert">{form.submitError}</p>}
<button type="submit" disabled={form.submitting}>
{form.submitting ? "Signing in…" : "Sign in"}
</button>
</form>
);
}INITIALlives outside the component on purpose. Passing an object literal would be a new identity every render, which invalidatesreset'suseCallbackand makes the hook's dependencies churn - the same object-identity problem as an effect that loops.fieldProps(name)spread onto an input is the ergonomic win: it isname,id,value,onChange,onBlurandaria-invalidin one, and it is why react-hook-form's API is calledregister.- The hook throws nothing on a failed submit - it catches, stores the message in
submitError, and always clearssubmittingin thefinally. errorForis wheretouchedis applied, so no component has to remember the gating rule.setValuesis returned as an escape hatch, for seeding the form from a fetched record or clearing one field from outside.