Form state and validation: values, errors, touched — and when to run the check
A form is three maps — values, errors, touched — plus a timing policy: validate on blur, re-validate on change once touched, gate on submit. One zod schema serves client and server; errors need aria-describedby and focus management, or some users never find them.
The signup funnel had a 31% drop on the email step, and session replays explained it brutally. The form validated onChange from the first keystroke: a user typed “j” and the field instantly went red — Please enter a valid email address — and stayed red, shouting at them, for the entire eleven characters it took to finish typing. Some users stopped to re-read what they had typed; some assumed their email “wasn’t accepted” and left. The fix the team shipped first — validate only on submit — produced the opposite injury: users filled nine fields, hit Submit, and got six errors at once with the viewport scrolled to the bottom, the first broken field invisible above the fold. A blind user on a screen reader filed the worst report: submit appeared to “do nothing”, because the error text was rendered as a detached red div that no assistive technology associated with any input, and focus stayed on the button. Three bugs, one root cause: the team had treated validation as a boolean — on or off — when it is actually a state machine over three maps and a timing policy.
The shape: values, errors, touched
Every form library converges on the same three parallel maps, because they answer three independent questions. values — what the user has entered (the controlled state from the previous lesson). errors — what is currently wrong with each field, derived from values by the validator. touched — which fields the user has finished interacting with, set when a field fires blur. The third map is the one hand-rolled forms forget, and it is the one that makes validation polite: an error should only be displayed for a touched field. Validation can run as often as you like; touched is the display gate that separates “this value is invalid” (a fact about data) from “tell the user about it now” (a UX decision).
const schema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(12, "At least 12 characters"),
});
function Signup() {
const [values, setValues] = useState({ email: "", password: "" });
const [touched, setTouched] = useState({});
const result = schema.safeParse(values);
const errors = result.success
? {}
: Object.fromEntries(
result.error.issues.map((i) => [i.path[0], i.message])
);
const showError = (field) => touched[field] && errors[field];
return (
<form noValidate onSubmit={/* gate on result.success, see below */ undefined}>
<input
value={values.email}
onChange={(e) => setValues({ ...values, email: e.target.value })}
onBlur={() => setTouched({ ...touched, email: true })}
aria-invalid={Boolean(showError("email"))}
aria-describedby={showError("email") ? "email-error" : undefined}
/>
{showError("email") ? (
<p id="email-error" role="alert">{errors.email}</p>
) : null}
</form>
);
}Note that errors here is derived state — computed from values during render, never stored separately. Storing errors in their own useState invites the classic desync: a value changes, the stale error keeps displaying.
Timing: the three policies and what each one punishes
onChange from the start punishes people for being mid-thought: every field is “invalid” until it is finished, so the form yells while the user types. onSubmit only punishes them for trusting you: ten fields of effort, then a wall of errors, often off-screen. onBlur is the compromise that field-tested UX research keeps landing on — the user signals “I am done with this field” by leaving it, and that is when the verdict is fair.
The production-grade policy is hybrid, sometimes called reward early, punish late: validate a field when it is first blurred; once a field is touched and has an error, re-validate on every change so the error disappears the instant the user fixes it (the reward must be immediate — making them blur again to clear a fixed error reads as broken); and run the full schema on submit as the final gate, because some fields may never have been touched at all. Numbers from the field: switching aggressive per-keystroke validation to blur-based hybrid policies is one of the most reliably positive form-UX changes teams measure, precisely because premature errors read as rejection.
▸Why this works
Why gate on submit at all if every field validates on blur? Two reasons. Untouched fields: a user can tab past a required field without typing — no change event, maybe no meaningful blur — so per-field validation never ran. And cross-field rules: “passwords must match” or “end date after start date” have no single home field; submit is the only moment when the whole object is fairly judged. The submit handler re-runs the full schema, marks all fields touched (so every error becomes visible), and refuses to send if anything fails.
One schema, two runtimes
Client-side validation is a UX device; it is not security, because the client is the attacker’s machine — anyone with DevTools can submit anything to your API. So the server must validate regardless, which historically meant writing every rule twice and watching them drift: the client allows 100 characters, the server truncates at 80, and the user gets a “saved” toast for data that was silently mangled. Schema libraries dissolve the duplication. A zod schema is a plain JavaScript value: define it once in a shared module, safeParse user input in the browser for instant feedback, and safeParse the request body on the server as the real gate. Same rules, same error messages, one source of truth — and TypeScript infers the form’s type from the schema (z.infer), so the types cannot drift either. The asymmetry that remains is legitimate: the server may check things the client cannot (email uniqueness against the database), and those errors come back asynchronously to be merged into the same errors map.
Errors users can actually find
An error message that is visually adjacent to its input is, to a screen reader, an unrelated paragraph somewhere in the document. Three wires fix that. aria-invalid on the input announces its state. aria-describedby pointing at the error element’s id makes assistive technology read the message when the input is focused — the association is explicit, not spatial. And on a failed submit, move focus: either to the first invalid input (so the user lands where the work is) or to an error summary at the top that links to each broken field. Without focus management, a failed submit is silence — the sighted user sees red somewhere, the screen-reader user hears nothing at all. The noValidate attribute on the form matters too: it disables the browser’s native bubbles so your accessible, styled errors are the single voice, instead of two systems disagreeing.
Why does a production form keep a separate touched map instead of just showing whatever is in errors?
A team validates thoroughly with zod in the browser and skips server-side validation to avoid duplicating rules. What is the actual exposure?
- 01Describe the hybrid validation timing policy and what each pure policy (onChange-always, onSubmit-only, onBlur-only) gets wrong.
- 02Why is the shared zod schema pattern strictly better than writing client and server validation separately, and what asymmetry legitimately remains?
Production form state is three maps with distinct jobs: values holds what the user entered, errors is derived from values by the validator on each render (never stored separately, so it cannot desync), and touched records which fields the user has finished with — it is the display gate that distinguishes the fact “this value is invalid” from the decision “tell the user now”. The timing policy that respects both halves: a field shows its error after first blur; once touched and in error it re-validates on every change so a fix clears instantly; submit re-runs the full schema with all fields marked touched, catching untouched fields and cross-field rules, then moves focus to the first failure or an error summary. Validation rules live once, in a zod schema shared between runtimes: the browser parse is fast feedback, the server parse is the real gate — client checks run on the attacker’s machine and prove nothing — and z.infer keeps the TypeScript types welded to the same source of truth, while genuinely server-only checks like email uniqueness merge their async errors into the same map. Accessibility is wiring, not styling: noValidate to silence native bubbles, aria-invalid on the input, aria-describedby pointing at the error element’s id so the message is announced with the field, and explicit focus management on failed submit — without those, the red text is invisible to anyone not looking at it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.