All articles
Design & UXSeptember 12, 2026 6 min read

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.

Form Errors Are a Timing Problem: When to Validate, When to Shut Up

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 typeFirst validationRe-validation after errorSuccess indicator?
EmailonBluronChangeOnly if async-checked
Password (new)onChange (strength meter)onChangeYes, per-rule checklist
Password (login)onSubmitonSubmitNo
Username (uniqueness)onBlur + debounced asynconChange after errorYes
Phone / postcodeonBluronChangeNo
Credit card numberonBlur (Luhn)onChangeCard brand icon
Required text fieldonSubmitonChangeNo
Confirm passwordonBlur (only after first password valid)onChangeNo
Date rangeonBlur of second fieldonChangeNo

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:

  1. Validate synchronous rules first (format, length). If those fail, don't fire the request.
  2. 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.
  3. Show a subtle spinner inside the field, not a full error state.
  4. 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:

  1. Turn off any global mode: 'onChange' in your form library. Set it to onBlur with reValidateMode: 'onChange'. That one change fixes most of the nagging.
  2. Walk every field and decide, explicitly, which of the three timing modes it belongs to. Write it in a comment next to the schema.
  3. Add aria-invalid and aria-describedby wiring to your input component once, so nobody has to remember.
  4. 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.

#UX#Forms#Accessibility#Frontend#Design Systems

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project