“Reward early, punish late” means a field never shows a new error while the user is still typing it, but an existing error disappears on the very keystroke that fixes it — so users are not scolded mid-word and not left staring at a stale message after they have corrected it.
The trade-offs between blur and change triggers are laid out in choosing between blur and change validation. Neither trigger alone is right: change-only validation shows “Enter a valid email” after the first character, and blur-only validation leaves a fixed field marked wrong until the user tabs away. The adaptive pattern uses both, asymmetrically, and the asymmetry depends on whether the field is currently showing an error.
Context and prerequisites
The rule has two halves:
- Punish late. An error may appear only on blur (or on submit). While the field has focus and is not already showing an error, keystrokes run validation silently — you may use the result for a success tick or a disabled state, but you do not display a new message.
- Reward early. Once a field is showing an error, validation runs on every change, and the moment the value becomes valid the error is removed — without waiting for blur. If the value is still invalid but for a different reason, update the message in place.
The pattern needs only the field’s own state: whether it is showing an error, and whether it has been blurred since the last edit. That makes it a four-state machine per field, and it composes with every other part of the form validation lifecycle.
The core pattern: a per-field timing machine
export type Phase = "pristine" | "editing" | "error" | "valid";
export interface FieldTiming { phase: Phase; shown: string | null }
export type Validate = (value: string) => string | null;
export function onChange(t: FieldTiming, value: string, validate: Validate): FieldTiming {
const msg = validate(value);
switch (t.phase) {
case "error":
// Reward early: the instant the value is valid, drop the message.
// If still invalid, keep showing — but the CURRENT reason, not the old one.
return msg ? { phase: "error", shown: msg } : { phase: "valid", shown: null };
case "pristine":
case "valid":
case "editing":
// Punish late: never introduce a new message while the user types.
return { phase: msg ? "editing" : "valid", shown: null };
}
}
export function onBlur(t: FieldTiming, value: string, validate: Validate): FieldTiming {
const msg = validate(value);
if (t.phase === "pristine" && value === "") {
// Tabbing through an untouched empty field: whether to show "required"
// now is a product decision. Most forms defer required errors to submit
// for fields the user never typed in.
return t;
}
return msg ? { phase: "error", shown: msg } : { phase: "valid", shown: null };
}
export function onSubmit(t: FieldTiming, value: string, validate: Validate): FieldTiming {
const msg = validate(value);
// After a submit attempt every field is in "reward early" mode: any error
// is shown now, and clears on the keystroke that fixes it.
return msg ? { phase: "error", shown: msg } : { phase: "valid", shown: null };
}
A thin adapter calls these from input, focusout and submit handlers and renders shown. The functions are pure, which makes the timing rules trivially unit-testable — the whole behaviour is a table of inputs and expected phases.
Step-by-step walkthrough
- Track a phase per field, not just an error string. The phase records why a message is or is not shown, which is what lets the same invalid value be silent in one state and displayed in another.
- On change, branch on whether an error is showing. If it is, revalidate and clear or update immediately. If it is not, validate silently and never add a message.
- On blur, show whatever validation says. This is the “late” in punish late: the user has finished with the field.
- Decide the empty-and-untouched case deliberately. Showing “required” when someone tabs through a field is defensible; so is deferring it to submit. Pick one and apply it everywhere, alongside the flags from touched vs dirty vs visited.
- On submit, move every field into the showing state. From then on each field behaves in “reward early” mode, which matches the post-submit rules in revalidating after the first submit.
- Announce appearances, not every update. A new error on blur is worth a polite announcement through
aria-describedby; clearing it should updatearia-invalidwithout speaking.
Failure modes and edge cases
1. Paste and autofill
A paste is a single change event carrying a complete value. Under punish-late it validates silently and the user gets no feedback until blur. That is acceptable for most fields; for a pasted one-time code or IBAN you may prefer to treat paste like blur. Autofill has no blur at all — it is reconciled at submit, per handling browser autofill in controlled inputs.
2. Async validators
Only the synchronous part of validation should follow this machine per keystroke. Async checks (availability, address lookup) should run on blur or after a debounce once the synchronous checks pass, and their results enter the same phases when they resolve.
3. Cross-field rules
When a group rule’s error is showing and the user edits the other member, reward early must re-run the group rule too, or the message stays until blur. Wire group rules to every member’s change handler while they are in the error phase.
4. Focus that never leaves
On the last field of a form, the next action is often pressing Enter, which submits without a blur. That is fine: submit moves every field into the showing state. But a single-field inline editor that saves on Enter needs its own “commit” event treated as blur.
5. Messages that change while typing
In the error phase, the message can change from “Enter a valid email” to “Email domain is not allowed” as the user types. That is correct but can be noisy for screen-reader users if each change is announced; announce only when the error first appears and when it clears, and let the updated text be read on next focus.
Verification checklist
Frequently Asked Questions
Where does the name "reward early, punish late" come from?
It is a long-standing name in the form-UX community for this asymmetric timing: reward the user (clear errors) as early as possible, punish them (show errors) as late as is still useful. Most mature form libraries implement some version of it, often as a “revalidate on change after first error” mode.
How does this map to React Hook Form's modes?
React Hook Form’s mode: "onTouched" shows errors after blur and then validates on every change, which is close to this pattern. reValidateMode: "onChange" controls the post-submit behaviour. The per-field machine here is the same idea, made explicit and library-independent.
Should success ticks follow the same timing?
Success indicators are safe to show early, because they never scold. But avoid showing a green tick for a merely well-formed value when an async check has not yet confirmed it; show pending until it does.