“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.

What each event does in each state In the pristine state, a change moves to editing silently, blur validates and shows an error if invalid, and submit validates and shows. While editing silently, changes validate without displaying, blur shows an error if invalid or moves to valid, and submit does the same. While showing an error, each change revalidates immediately and clears the error as soon as the value is valid, or updates the message if it is invalid for another reason. In the valid state, a change that makes the value invalid moves back to editing silently, so no new error appears until blur. State on change on blur on submit pristine → editing (silent) validate; show if invalid validate; show editing (silent) validate, do not show show if invalid, else valid show if invalid showing error revalidate; clear the moment valid stay stay valid if now invalid → editing (silent) show if invalid show if invalid

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

  1. 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.
  2. 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.
  3. On blur, show whatever validation says. This is the “late” in punish late: the user has finished with the field.
  4. 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.
  5. 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.
  6. Announce appearances, not every update. A new error on blur is worth a polite announcement through aria-describedby; clearing it should update aria-invalid without speaking.
Typing an email under three timing policies Under change-only validation, an error is visible from the first keystroke until the address becomes valid, including while the user is still typing correctly. Under blur-only validation, no error shows while typing, the error appears on blur, and it stays visible while the user corrects the value until they blur again. Under adaptive validation, no error shows while typing, it appears on blur, and it disappears on the exact keystroke that makes the address valid. Change-only error from the first keystroke until valid Blur-only shown on blur, stale after fix Adaptive shown on blur, gone on fix 0s 2s 4s 6s 8s 10s Events: typing 0–3 s, blur at 4 s, user refocuses and fixes at 5–7 s (valid at 7 s), blur again at 9 s.

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.

The asymmetry in one picture Errors may appear only on blur or on submit, never mid-keystroke, because the user has not finished and a premature message is noise. Errors may disappear on any keystroke, immediately, because leaving a fixed field marked wrong erodes trust and tempts users to keep changing a correct value. Appear late Only on blur or submit. Never mid-keystroke: the user has not finished. A premature error is noise. Disappear early On the keystroke that fixes it. No waiting for blur. A stale error makes users change a correct value.

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.


Related

← Form Validation Lifecycle