Forms People Can Actually Finish
Watch five users fill out your signup form and you'll rewrite it by Friday. On validation timing, error placement, preserving input, and the disabled-button trap.
The first time I sat in on a usability session for a form I'd built, I lasted about ninety seconds before I wanted to crawl under the table. The participant typed the first three letters of her email and the field turned red — "Please enter a valid email address" — scolding her for not having finished typing. She paused, visibly annoyed, and said to the moderator: "It's yelling at me already?"
She was right. It was yelling at her. I had wired validation to fire on every keystroke, because that's what the example in the library docs did, and I had never once watched a person use it.
Forms are where your product asks the user to do work. Every piece of friction — premature errors, vanished input, mystery-disabled buttons — is the product making that work harder. And nearly all of it is invisible in a code diff unless you know what to look for.
Validation timing: don't interrupt, don't ambush
There are three common timing strategies, and two of them are hostile:
- Validate on every keystroke (eager): yells at users for incomplete input, like my email field. Hostile.
- Validate only on submit: lets the user fill out fourteen fields, hit submit, and then reveals five errors — an ambush at the finish line. Hostile in the other direction.
- Validate on blur, re-validate on change: the field stays quiet while the user is typing, checks when they leave it, and — crucially — once a field has shown an error, it re-checks on every keystroke so the error disappears the instant it's fixed.
That third pattern is sometimes called "reward early, punish late," and it's the one I look for in review. Errors appear at natural pauses; success feedback is immediate. Most form libraries support it directly:
// react-hook-form: quiet while typing, checks on blur,
// then live re-validation once a field has erred
useForm({ mode: "onBlur", reValidateMode: "onChange" });
One nuance: some checks are legitimately async (username availability, promo codes). Those should still respect the same rhythm — debounce, show a subtle pending indicator on the field, and never block the user's typing while you wait.
Error placement: at the scene, in human words
Where the error message lives matters as much as when it appears. The rules I hold PRs to:
- Next to the field, not just at the top. A summary banner ("3 errors below") is fine as an addition on long forms, but each error must sit adjacent to its field. Users fix errors one at a time, where they are.
- Specific and actionable. "Password must be at least 12 characters" beats "Invalid password." Tell the user what to do, not just that they're wrong. And say it in words a person would use — never leak
error.codeor a server validation string. - Announced, not just painted. Red borders are invisible to screen readers and to about 8% of men with color-vision deficiency. The field needs
aria-invalid, and the message needs to be programmatically associated:
<input
id="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" role="alert">{errors.email.message}</p>
)}
- On submit with errors, move focus to the first invalid field. Otherwise keyboard and screen-reader users are left hunting.
The disabled-button trap
Here's a pattern that looks like good UX and quietly isn't: disabling the submit button until the form is valid.
The intention is kind — prevent invalid submissions. The experience is a locked door with no sign. The user finishes typing (they believe), sees a grayed-out button, and gets no information about why. Which field is wrong? The checkbox they missed at the top? Disabled buttons don't explain; they can't even be clicked to trigger an explanation. They're also skipped in tab order and frequently fail contrast requirements, so the users who most need feedback get the least.
The kinder pattern: keep the button enabled, and let clicking it run validation. The click becomes the user's question — "am I done?" — and your inline errors, plus focus moved to the first problem, are the answer. Reserve disabled (or better, aria-disabled with a visible reason) for the brief in-flight state after submission, where it prevents double-submits — and pair it with a spinner and a label change ("Saving…") so it reads as progress, not rejection.
Preserve input like it's money
Nothing — nothing — makes users abandon a form faster than losing what they typed. The classic offenders:
- A failed submit that re-renders the form empty. If the server rejects, every field the user filled must still be filled when the error appears. If your form does a full-page POST round trip, the server must echo values back; if it's client-side, don't you dare reset state in the error path.
- Navigating away and back (mobile users get interrupted constantly — an OTP text arrives, they switch apps, the WebView reloads). For long forms, persist drafts to
sessionStorageas the user types. - The multi-step wizard where "Back" wipes the step you already completed.
- A remount that resets everything because a parent component changed a
keyor conditionally unmounted the form — a bug that arrives from a completely different part of the diff.
My blunt review heuristic: find every path that leads away from a filled-in form — error, back button, tab switch, session timeout — and ask what happens to the user's work on each one. "It's gone" needs a justification, and there rarely is one.
Reviewing generated forms
Forms are among the most commonly AI-generated UI on any team — "make me a signup form with validation" is a one-sentence prompt with a twenty-file answer. What comes back is usually structurally decent: a schema, a form library, styled fields. But the defaults skew hostile in exactly the ways above. I consistently see mode: "onChange" (keystroke-yelling), a disabled={!isValid} submit button (the locked door — this one appears in nearly every generated form I've reviewed, because it's everywhere in training data), error text with no aria-describedby, and a form.reset() in the error handler because it looked symmetrical with the success handler.
None of these are exotic. They're default-shaped. Which is the point: the assistant reproduces the average form on the internet, and the average form on the internet fights its users. The reviewer's job is to hold the diff to a higher standard than the average.
Before you approve a form
The checklist I actually run:
- Type into every field slowly — does anything yell before you finish?
- Submit a half-empty form — errors inline, human-worded, focus moved to the first one?
- Fix an error — does the message clear immediately?
- Is the submit button enabled and informative, rather than a gray mystery?
- Force a server failure — is every field still filled?
- Tab through the whole thing keyboard-only, then once more with a screen reader if you can.
A form is a conversation where you're asking for a favor. Be quiet while they're talking, be specific when something's wrong, never make them repeat themselves, and don't lock the door while they're standing at it.