Form Validation Timing: When to Yell at Users and When to Shut Up
Validation on every keystroke is hostile. Validation only on submit is lazy. Here's a timing model for form errors that respects users and still catches mistakes before they hurt.
Validating every keystroke makes a form feel like a nagging coworker. Validating only on submit makes it feel like a bureaucrat who waited until page five to tell you page one was wrong. The right answer sits between those two extremes, and the shape of it depends on the field, not the framework.
This is the timing model we use when we build forms for clients, and the reasoning behind each rule.
The four moments you can validate
Every validation strategy is a combination of four events. Pick the wrong one for a field and the form feels wrong even when the logic is correct.
- onChange — fires on every keystroke, toggle, or selection
- onBlur — fires when the field loses focus
- onSubmit — fires when the user tries to send the form
- debounced onChange — fires onChange, but waits for a pause (usually 300 – 600ms)
The mistake most teams make is picking one of these and applying it to the whole form. Email, password, credit card number, and "preferred name" all deserve different treatment.
Why onChange on everything is hostile
Typing j into an email field and immediately seeing "Please enter a valid email address" is one of the worst feelings in software. The user has not finished. You know they have not finished. You are telling them off for not being done yet.
The one exception is when the field has a clear, bounded format and the user benefits from feedback mid-stream — more on that below.
The rules we actually ship
Here is the decision tree we hand to designers and engineers on new projects.
Rule 1: Validate on blur the first time, on change after an error
This is the single most important rule. Before the user has made a mistake in a field, be quiet. Once they have, help them fix it in real time.
// Pseudocode — adapt to your form library of choice
function useFieldValidation(name: string) {
const [touched, setTouched] = useState(false);
const [error, setError] = useState<string | null>(null);
const onBlur = (value: string) => {
setTouched(true);
setError(validate(name, value));
};
const onChange = (value: string) => {
// Only re-validate on change if the field is already in an error state
if (touched && error) {
setError(validate(name, value));
}
};
return { onBlur, onChange, error: touched ? error : null };
}
The psychology: you are rewarding the user for correcting themselves. The error disappears the instant the input becomes valid, which turns a scolding into a conversation.
Rule 2: Submit always re-validates everything
No matter what state individual fields are in, pressing Submit must run the full validation pass and surface every error. Users skip fields. Users paste into fields and never blur. Users tab past required inputs. Submit is your safety net.
And when submit fails, move focus to the first invalid field. Not scroll — focus. Keyboard users and screen reader users need the cursor to land there.
const firstInvalid = formRef.current?.querySelector<HTMLElement>(
'[aria-invalid="true"]'
);
firstInvalid?.focus();
Rule 3: Debounce only for async checks
Username availability. Promo code lookup. Address autocomplete. These need debouncing because they hit a server, and you do not want to fire a request per keystroke.
For pure client-side validation (regex, length, required), debouncing adds latency with no benefit. Validate instantly in the onChange-after-error case.
A sensible debounce for async is 400 – 600ms. Shorter feels jumpy, longer feels broken. And always show a pending indicator so the user knows something is happening between "typing" and "result".
Field-by-field cheat sheet
This is what we actually ship, after a lot of arguing in design reviews.
| Field type | First validation | After error |
|---|---|---|
| onBlur | onChange | |
| Password (create) | onChange (strength meter) | onChange |
| Password (login) | onSubmit only | onSubmit |
| Phone number | onBlur | onChange |
| Credit card number | onChange (format + Luhn when 16 digits) | onChange |
| CVV | onBlur | onChange |
| Username (unique) | debounced onChange (async) | debounced onChange |
| Required text | onBlur | onChange |
| Checkbox (ToS) | onSubmit | onChange |
| Date of birth | onBlur | onChange |
The login password rule surprises people. Why not validate length on blur? Because the only check that matters on login is "does this match the server", and you cannot know that client-side. Telling a user "password must be 8 characters" when they have typed their actual 12-character password that just has a typo in it is noise. Submit, get the real answer, show the real error.
The credit card exception
Card numbers are the one place aggressive onChange validation feels good. Format the number as the user types (4242 4242 4242 4242), run Luhn when they reach 16 digits, and show the detected brand icon. The feedback is useful because the format is known and the user cannot easily spot their own typo in a 16-digit string.
Making the errors themselves not awful
Timing is half the problem. The other half is the message.
Say what to do, not what went wrong
- Bad:
Invalid email - Better:
Email must include an @ - Best:
Did you mean user@gmail.com?(when you can detect the typo)
Never validate against rules you have not stated
If your password requires a symbol, say so before the user submits. Hidden rules revealed only on failure are the single most-hated pattern in form design. Show the rules inline, and ideally check them off as the user types.
Use aria-live, but carefully
Screen readers need to hear errors. But if you announce every keystroke-level change, the user drowns.
<div
id="email-error"
role="alert"
aria-live="polite"
>
Email must include an @
</div>
<input
type="email"
aria-invalid="true"
aria-describedby="email-error"
/>
aria-live="polite" waits for a pause before announcing. role="alert" makes it assertive. Use polite for inline validation, assertive only for submit-level failures that block the user.
And debounce the announcement itself — if the error text changes three times in 500ms as the user types, only announce the final version. Our usual approach is to update the visible text instantly but delay the aria-live update by 500 – 800ms.
The success-state trap
Green checkmarks on valid fields feel satisfying in Figma and are often clutter in production. If every valid field gets a green tick, the user's eye has nowhere to rest, and the real errors are harder to find.
Our rule: show success state only when success itself is information. Username availability — yes, show the green. Email format correct — no, silence is sufficient. Password strength — show the meter, not a checkmark.
A note on form libraries
React Hook Form, Formik, TanStack Form, and the native FormData API can all implement the model above. The library does not matter. What matters is that you set the mode or equivalent correctly:
- React Hook Form: use
mode: 'onBlur'withreValidateMode: 'onChange'. That one line encodes Rule 1. - TanStack Form: configure
validatorswithonBlurandonChangeAsyncDebounceMsper field. - Native: wire
blurandinputlisteners manually, which is honestly fine for small forms.
If your form library forces one mode across every field, that is a sign to drop down a layer and manage validation per field.
Where we'd start
If you only have an afternoon, do this in order:
- Switch your form to validate on blur first, on change after error. This single change makes most forms feel 50% less annoying.
- On submit failure, move focus to the first invalid field.
- Rewrite your error messages to say what to do, not what went wrong.
- Add
aria-invalidandaria-describedbyto every input that can show an error. - Audit your green checkmarks and remove the ones that are not telling the user anything new.
If you want help auditing a conversion-critical form end to end, that is the kind of work our design and UX team does on client projects — timing, copy, accessibility, and the measurement to prove it moved the number.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading
Disabled Buttons Are a UX Smell: What to Do Instead
Disabling a submit button feels polite and safe. It's usually neither. Here's why the greyed-out button pattern breaks accessibility, hides your real errors, and what to ship instead.
Loading Buttons Without Lying: Pending States That Actually Communicate
Spinners inside buttons became a reflex, not a decision. Here's how to design and build pending states that tell the truth about what's happening — and what to do when the network takes longer than you promised.

Empty States Are the Feature You Shipped Without Designing
Empty states are the first screen most users actually see. Here's how to design them so they teach, convert, and don't make your product feel broken on day one.
