Lesson 34 of 80
Controlled inputs are the easy part. The real form-state questions are when to validate, how to track submission, and how to keep both from fighting each other.
15 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
Making a single input controlled is one line of useState. A real form is a different problem: several fields that each need a current value, an error message that shouldn't appear before the user's had a chance to type, and a submit button that needs to know "is this in flight, did it fail, is the form even valid yet." Those three concerns — field values, validation timing, and submission status — are easy to tangle together into one giant reducer that's hard to reason about. Keeping them as separate, explicit pieces of state is what actually makes a form maintainable.
function SignupForm() {
const [values, setValues] = useState({ email: "", password: "" });
const [touched, setTouched] = useState({});
const [status, setStatus] = useState("idle"); // idle | submitting | error | success
const errors = validate(values); // pure function, recomputed every render — no state, no effect
function handleChange(field) {
return (e) => setValues((v) => ({ ...v, [field]: e.target.value }));
}
function handleBlur(field) {
return () => setTouched((t) => ({ ...t, [field]: true }));
}
async function handleSubmit(e) {
e.preventDefault();
setTouched({ email: true, password: true }); // reveal every error on submit attempt
if (Object.keys(errors).length > 0) return;
setStatus("submitting");
try {
await createAccount(values);
setStatus("success");
} catch {
setStatus("error");
}
}
return (
<form onSubmit={handleSubmit}>
<input
value={values.email}
onChange={handleChange("email")}
onBlur={handleBlur("email")}
/>
{touched.email && errors.email && <p>{errors.email}</p>}
<input
type="password"
value={values.password}
onChange={handleChange("password")}
onBlur={handleBlur("password")}
/>
{touched.password && errors.password && <p>{errors.password}</p>}
<button disabled={status === "submitting"}>
{status === "submitting" ? "Creating account…" : "Sign up"}
</button>
{status === "error" && <p>Something went wrong. Try again.</p>}
</form>
);
}
Three things are deliberately kept separate. values is just the raw field data. errors is never stored as state at all — it's recomputed from values on every render, the same "derive, don't sync" move this track covers for computed values in general, just applied to validation specifically. touched decides when an already-computed error is actually shown — without it, every field would show an error the instant the form mounts, before the user has typed a single character. status is a small state machine, not a isLoading boolean, because a form genuinely has more than two states worth distinguishing (a boolean can't tell "hasn't tried yet" apart from "succeeded").
errors doesn't need its own useState. It's a pure function of values — storing it separately and syncing it with an effect on every values change is the exact unnecessary-state-plus-effect anti-pattern this track calls out elsewhere, just wearing a form-validation costume.status string beats a pile of booleans. isSubmitting, isError, isSuccess as three separate booleans can represent impossible combinations (submitting and error, both true at once) that a single string genuinely can't.touched is not optional plumbing — it's the actual UX decision. Deciding when an error becomes visible (on blur, on submit, never before the first submit attempt) is a real design choice, and forgetting to track it is why so many forms show every error immediately on page load.This lesson assumes the controlled-input mechanics — a value driven by state, updated via onChange — as its starting block and asks a different question: once you have several of those inputs together, what state actually needs to exist around them. The errors value here is a direct application of deriving state during render instead of storing and syncing it, the same habit this track treats as the single highest-leverage move for any computed value, not just form validation.