Form Errors Are a Timing Problem: When to Validate, When to Shut Up
Most form validation fails not because the rules are wrong, but because the timing is. Here's a field-tested model for when to validate, when to wait, and when to say nothing at all.

Every team ships a signup form. Almost every team gets validation timing wrong — either yelling at users mid-keystroke or letting them submit garbage and then dumping ten red errors at once. The rules aren't the hard part. The timing is.
This is the model we use on client work, why it works, and how to wire it up without turning your form component into a state machine you'll regret in six months.
The three timing modes, and what they're actually for
There are really only three moments a form can complain: while the user is typing, when they leave a field, or when they try to submit. Each one carries a different social contract.
- onChange (while typing): interrupts the user. Should almost never fire an error. Reserved for positive feedback and hard constraints (max length, forbidden characters).
- onBlur (after leaving the field): the user has signalled "I'm done with this thought." This is the correct moment for most validation errors.
- onSubmit (on submit attempt): the last-chance sweep. Catches anything that couldn't be checked earlier (server-side uniqueness, cross-field rules) and re-surfaces anything the user ignored.
The mistake most teams make is validating everything onChange because their form library makes it a one-line prop. It feels responsive. It reads as nagging.
The exception: re-validating an already-errored field
Once a field has shown an error, switch it to onChange. The user is now actively trying to fix the problem, and silence between keystrokes feels broken. The moment their input becomes valid, clear the error immediately — don't wait for another blur.
This single rule accounts for maybe 40% of the perceived quality difference between a good form and a mediocre one.
A decision table you can actually ship
Here is the matrix we hand to designers and engineers when we start a form-heavy project:
| Field type | First validation | Re-validation after error | Success indicator? |
|---|---|---|---|
| onBlur | onChange | Only if async-checked | |
| Password (new) | onChange (strength meter) | onChange | Yes, per-rule checklist |
| Password (login) | onSubmit | onSubmit | No |
| Username (uniqueness) | onBlur + debounced async | onChange after error | Yes |
| Phone / postcode | onBlur | onChange | No |
| Credit card number | onBlur (Luhn) | onChange | Card brand icon |
| Required text field | onSubmit | onChange | No |
| Confirm password | onBlur (only after first password valid) | onChange | No |
| Date range | onBlur of second field | onChange | No |
A few things worth calling out. Login passwords are onSubmit only — you have no idea what rules the account was created under, and telling someone their password is "too short" when it's the one they've used for two years is a small betrayal. Required text fields also wait for submit; nagging someone about an empty field they haven't touched yet is the definition of anxious UI.
The password field deserves its own conversation
New-password fields are the one place onChange validation is not just acceptable, it's expected. But the pattern matters. A single "password too weak" error that flips on and off is worse than useless — it teaches the user nothing.
What works: a persistent checklist that renders all rules from the start, and ticks them off as they're satisfied.
function PasswordRules({ value }: { value: string }) {
const rules = [
{ label: 'At least 12 characters', test: (v: string) => v.length >= 12 },
{ label: 'One number', test: (v: string) => /\d/.test(v) },
{ label: 'One symbol', test: (v: string) => /[^A-Za-z0-9]/.test(v) },
];
return (
<ul aria-live="polite" className="text-sm mt-2">
{rules.map(({ label, test }) => {
const passed = test(value);
return (
<li key={label} className={passed ? 'text-green-700' : 'text-slate-500'}>
<span aria-hidden="true">{passed ? '✓' : '○'}</span>{' '}
<span>{label}</span>
<span className="sr-only">{passed ? ' — satisfied' : ' — not yet satisfied'}</span>
</li>
);
})}
</ul>
);
}
Note the aria-live="polite" and the screen-reader-only status text. A visual tick is invisible to assistive tech unless you tell it what changed.
Async validation: debounce, don't rate-limit the user
Username availability, email-already-registered, coupon codes — these need a server round-trip. The pattern:
- Validate synchronous rules first (format, length). If those fail, don't fire the request.
- Debounce the request by 400–600ms after the user stops typing. Under 300ms and you'll spam your own endpoint; over 800ms and it feels laggy.
- Show a subtle spinner inside the field, not a full error state.
- If the request is in flight when the user tries to submit, wait for it — don't submit optimistically and then jerk them back.
const checkUsername = useDebouncedCallback(async (value: string) => {
if (!/^[a-z0-9_]{3,20}$/.test(value)) return;
setStatus('checking');
const { available } = await api.checkUsername(value);
setStatus(available ? 'ok' : 'taken');
}, 500);
One thing we've learned the hard way: cancel in-flight requests when the value changes. Otherwise a slow response for "jsmit" arrives after the user has typed "jsmith" and you'll flash a stale error. AbortController or a request-id check will save you a bug report.
Error messages: what the field couldn't tell them yet
Timing is half the battle. The other half is what the message actually says. A few rules we enforce in review:
- Say what's wrong, in the field's own vocabulary. "Enter a valid email" is worse than "This looks like it's missing an @".
- Never blame the user. "Invalid input" is a lazy engineer's phrase. What is invalid, and how would they know?
- Don't repeat the label. If the field says "Email", the error doesn't need to say "Email is required." Just "Required."
- Server errors get the same treatment. "Something went wrong" is not an error message, it's an apology for not writing one.
Accessibility: the parts that get skipped
A validation system that fails screen readers isn't complete, it's a compliance liability. The minimum:
- Associate the error with the input via
aria-describedby. - Set
aria-invalid="true"on the input when it's in error state. - Put the error message container inside an
aria-live="polite"region so it's announced when it appears. - Don't rely on colour alone — pair the red with an icon or a leading word like "Error:".
<input
id="email"
type="email"
aria-invalid={!!error}
aria-describedby={error ? 'email-error' : undefined}
/>
{error && (
<p id="email-error" role="alert" className="text-red-700 text-sm mt-1">
{error}
</p>
)}
role="alert" is stronger than aria-live="polite" — use it for errors that appear after user action, save polite for ambient status like the password checklist.
The submit button question
A disabled submit button that waits for the form to be valid is one of the most-copied and least-defended patterns in web UI. We covered why disabled buttons are a UX bug in a separate post, but the short version applies here: keep the button enabled, and on submit, run all validators, focus the first invalid field, and scroll it into view.
This does two things. It gives the user a clear action ("try to submit") that always responds. And it turns your submit handler into the single, authoritative source of truth for "is this form actually done?" — which is where that logic belonged all along.
Where we'd start
If you're auditing an existing form today, do this in order:
- Turn off any global
mode: 'onChange'in your form library. Set it toonBlurwithreValidateMode: 'onChange'. That one change fixes most of the nagging. - Walk every field and decide, explicitly, which of the three timing modes it belongs to. Write it in a comment next to the schema.
- Add
aria-invalidandaria-describedbywiring to your input component once, so nobody has to remember. - Rewrite your three worst error messages. Show them to someone who didn't build the form.
That's a half-day of work and it's the single highest-leverage UX pass you can do on a form-heavy product. If you want help thinking through the whole flow — signup, checkout, onboarding — that's the kind of work we do under design and product engineering.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Toast Notifications Are a Memory Test: Designing Feedback That Respects Attention
Toasts fade in three seconds and expect users to catch, read, and act on them. Here's how we redesigned notification patterns to stop punishing distracted users and keyboard-only users alike.

Design Tokens That Survive Contact With Engineering: A Naming Convention That Scales
Most design token systems die at the second theme. Here's a three-tier naming convention that survives dark mode, brand refreshes, and the inevitable Tailwind migration.

Empty States Are Your Second Homepage: Designing Them Like Product Surface
Empty states get treated like an afterthought — a shrug icon and a sentence. They're actually one of the highest-leverage screens in your product. Here's how to design and ship them like it.
