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.
Step-by-Step Walkthrough
-
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.
-
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.
-
Track
hasShownErrorper field. This is the phase flag, and it is what makes βquiet, then liveβ possible without a special case per field. -
Let a submit override everything. After a submit attempt the reader has asked for judgement on the whole form, so every field speaks.
-
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.
-
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.
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 β the state machine these triggers drive
- Revalidating After the First Submit β the override, implemented
- Debouncing Validation Triggers in React β keeping the change phase cheap
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.