The exact problem: a field validates on every keystroke, so an email address is marked invalid five characters into being typed β€” or it validates only on submit, so a reader fills in twelve fields before learning the second one was wrong.

Context and Prerequisites

The trade-off between modes is laid out in form validation lifecycle. This page is about implementing the mode as a per-field state rather than a global setting, because a real form wants different behaviour for different fields and a single mode: 'onBlur' cannot express that.

Core Pattern: The Trigger Is Per Field, Per Phase

type Phase = 'composing' | 'repairing';
type Trigger = 'change' | 'blur' | 'submit';

interface FieldPolicy {
  /** When this field may FIRST show an error. */
  readonly firstShowOn: Trigger;
  /** Once it has shown one, when it re-evaluates. */
  readonly thenOn: Trigger;
}

const DEFAULT: FieldPolicy = { firstShowOn: 'blur', thenOn: 'change' };

// Only fields with a reason to differ get an entry; everything else takes the
// default, which keeps the exceptions visible.
const POLICY: Record<string, FieldPolicy> = {
  // A confirmation field is compared against something the reader already
  // typed, so live feedback while typing is genuinely useful.
  passwordConfirm: { firstShowOn: 'change', thenOn: 'change' },
  // A remote uniqueness check is expensive; never fire it per keystroke.
  username:        { firstShowOn: 'blur',   thenOn: 'blur'   },
  // Cross-field rules cannot be judged until everything is present.
  endDate:         { firstShowOn: 'submit', thenOn: 'change' },
};

export function shouldShow(
  field: string,
  trigger: Trigger,
  state: { hasShownError: boolean; formSubmitted: boolean },
): boolean {
  const p = POLICY[field] ?? DEFAULT;
  if (state.formSubmitted) return true;            // after a submit, everything speaks
  return state.hasShownError ? trigger === p.thenOn || p.thenOn === 'change'
                             : trigger === p.firstShowOn;
}

The two-phase shape β€” firstShowOn then thenOn β€” is what makes the common case feel right without a special case. Before the reader has been told anything, the field stays quiet until blur. Once it has spoken, it re-evaluates live, so the message disappears the moment the value becomes valid rather than waiting for another blur.

Quiet while composing, live while repairing Phase one, composing: the reader is typing for the first time and the field shows nothing, however wrong the partial value looks. On blur it validates once and, if it fails, shows a message. Phase two, repairing: the reader is now correcting a known problem, so every keystroke re-validates and the message clears the instant the value becomes valid. The transition happens once per field, and a submit attempt moves every field into the repairing phase at once. phase 1 β€” composing typing β†’ nothing shown blur β†’ validate once the reader has not finished a thought yet interrupting here reads as nagging first error phase 2 β€” repairing typing β†’ re-validate every keystroke the message clears the moment it passes the reader is fixing a known problem immediate confirmation is what repair needs The transition happens once per field, and a submit attempt moves every field into phase 2 at once. Both phases use the same rules β€” only the trigger changes, so a field can never pass in one phase and fail in the other. Four fields, four policies, one default A plain text field takes the default: quiet until the first blur, live afterwards. A password confirmation shows on change from the start, because it is compared against something the reader has already typed and live feedback genuinely helps. A username with a remote uniqueness check stays on blur in both phases, because live would mean a request per keystroke. An end date that must be after a start date waits for submit, because it cannot be judged until both fields exist. Field First shown on Then on Why any text field blur change the default password confirm change change compared to known input username, remote check blur blur live would be per keystroke end date submit change needs both fields present Three exceptions in a form of forty fields. If the table grows much past that, the default is the wrong default.

Step-by-Step Walkthrough

  1. Default to blur-then-change. It is right for the large majority of text fields, and making it the default keeps the policy table short enough to review.

  2. List only the exceptions. A confirmation field, a remote check, a cross-field rule. If the table grows past a handful of entries, the default is wrong.

  3. Track hasShownError per field. This is the phase flag, and it is what makes β€œquiet, then live” possible without a special case per field.

  4. Let a submit override everything. After a submit attempt the reader has asked for judgement on the whole form, so every field speaks.

  5. Keep the rules identical across phases. Only the trigger changes. A field that passes on blur and fails on change is a form that appears to change its mind.

  6. Debounce the change-phase evaluation. Re-validating on every keystroke is fine for a regex and not fine for a schema parse over a large object; the debounce belongs in the trigger, not in the rule.

Failure Modes and Edge Cases

1. Autofill fires neither trigger reliably

A password manager filling several fields at once may produce input events but no blur. A field whose policy is firstShowOn: 'blur' then never validates. Treat a fill of a previously empty field as a blur-equivalent, or re-validate everything on submit β€” which the submit override already does.

2. A field the reader never focuses

Tabbing past an empty required field produces a blur, so it validates. Never focusing it at all produces nothing, and the field is unjudged until submit. That is correct, and it is why the submit override is not optional.

3. Live validation on an expensive rule

thenOn: 'change' with a remote check is a request per keystroke. Keep expensive rules on blur in both phases, and let the cheap structural rules go live.

4. Radio and checkbox groups

A group blurs when focus leaves the group, not each option. Attach the blur listener to the fieldset with capture, or the policy fires on every arrow key.

5. Select elements

A <select> produces change on choose and blur on leave, usually together. Both policies collapse to the same behaviour, which is fine β€” do not special-case it.

Two failures the policy is chosen between Validating too early produces a field marked invalid five characters into an address that is being typed, a message the reader has to ignore, and β€” over a whole form β€” a habit of ignoring messages that matters when one finally does. Validating too late produces a reader who fills twelve fields before learning the second one was wrong, and a repair pass that starts by scrolling back up. The default, quiet then live, avoids both by changing behaviour at the moment the reader stops composing and starts repairing. too early invalid five characters into an address a message that must be ignored and then all of them are ignored the cost compounds across the form too late twelve fields filled before the news a repair pass that starts by scrolling several problems arriving at once and the submit already refused Quiet then live avoids both, because it switches exactly when the reader stops composing and starts repairing.

Verification Checklist

Common Pitfalls

  • One mode for the whole form. A confirmation field, a remote check and a cross-field rule want three different behaviours, and a single setting forces two of them to be wrong.
  • Different rules per phase. Only the trigger should change between composing and repairing. A field that passes on blur and fails on change is a form that appears to change its mind.
  • Blur listeners on individual options. A radio group blurs when focus leaves the group, not each option. Listen on the fieldset, or the policy fires on every arrow press.
  • Live validation on an expensive rule. Making everything live after the first error turns a remote uniqueness check into a request per keystroke. Keep expensive rules on blur in both phases.
  • Assuming a fill produces a blur. A password manager filling several fields at once may emit input events and no blur at all, so a blur-only policy never judges them. The submit override is what catches this.

Related

← Form Validation Lifecycle

Frequently Asked Questions

Should validation mode be a global form setting?

Only as a default. A confirmation field genuinely wants live feedback, a remote uniqueness check must not fire per keystroke, and a cross-field rule cannot be judged until its inputs exist β€” three different behaviours in one form. Make blur-then-change the default and keep a short table of exceptions, so the unusual policies are visible rather than buried in component props.

Why not validate on change from the start?

Because an email address is invalid for most of the time it is being typed, and telling the reader so five characters in is noise they have to ignore. The cost is not just annoyance: a field that cries wolf while composing trains readers to skip past its message when it finally matters. Waiting for blur costs nothing β€” the reader finds out before they submit either way.

What about fields the reader never touches?

They stay unjudged until the submit attempt, which is correct: showing an error on a field nobody has interacted with is a form telling the reader off for not having got there yet. The submit override is what guarantees they are judged eventually, which is why it is not optional even when every field has an explicit policy.