Most “the error appeared too early” and “the error never appeared” bugs come from gating error display on the wrong interaction flag — usually dirty, which a keyboard user tabbing through a required field never sets.

Dirty and pristine state tracking is about one of these flags. In practice a form keeps four, and each answers a distinct question. Treating them as interchangeable produces the classic symptoms: required errors that never show for fields the user skipped, errors that flash on focus, and an unsaved-changes prompt after the user merely clicked around.


Context and prerequisites

The four flags, defined by the question each answers:

  • visited — “has focus ever entered this field?” Set on focus.
  • touched — “has focus ever left this field?” Set on blur. This is the one that means “the user has had their chance”.
  • dirty — “does the value differ from the baseline?” Derived by comparison, not set by an event, and it can become false again.
  • submitted — a form-level flag: “has the user tried to submit at least once?”

Error display should key off touched or submitted. The unsaved-changes guard should key off dirty. Analytics about field abandonment can use visited without touched. Using dirty for error display is the common mistake, because a user who tabs past a required field leaves it touched but not dirty, and never sees why submit fails until they press it.

The four flags and the question each answers Visited is set on focus, never reverts, and is useful for analytics and for deciding whether to show a hint. Touched is set on blur, never reverts until reset, and gates per-field error display. Dirty is derived by comparing value and baseline, reverts when the value returns to the baseline, and drives the unsaved-changes guard and the save button. Submitted is set on the first submit attempt and gates showing every error at once. Flag Set by Reverts? Should drive visited focus no hints, abandonment analytics touched blur no (until reset) per-field error display dirty value ≠ baseline yes, when value returns unsaved guard, save button submitted first submit attempt no (until reset) reveal all errors at once

The core pattern: flags set by the right events, and one display rule

export interface FieldFlags { visited: boolean; touched: boolean; }
export interface FormFlags { submitted: boolean; fields: Record<string, FieldFlags>; }

export function createFlagTracker(form: HTMLFormElement, onChange: (f: FormFlags) => void) {
  const flags: FormFlags = { submitted: false, fields: {} };
  const field = (name: string) => (flags.fields[name] ??= { visited: false, touched: false });

  // focusin/focusout bubble, unlike focus/blur, so one listener covers every
  // field — including ones rendered later, and ones inside custom elements
  // that retarget focus events to the host.
  const onFocusIn = (e: FocusEvent) => {
    const el = e.target as HTMLInputElement;
    if (!el.name) return;
    if (!field(el.name).visited) { field(el.name).visited = true; onChange(flags); }
  };
  const onFocusOut = (e: FocusEvent) => {
    const el = e.target as HTMLInputElement;
    if (!el.name) return;
    // Focus moving between two radios in the same group is not "leaving".
    const next = e.relatedTarget as HTMLInputElement | null;
    if (next?.name === el.name) return;
    if (!field(el.name).touched) { field(el.name).touched = true; onChange(flags); }
  };
  const onSubmit = () => { flags.submitted = true; onChange(flags); };

  form.addEventListener("focusin", onFocusIn);
  form.addEventListener("focusout", onFocusOut);
  form.addEventListener("submit", onSubmit, true); // capture: set before validation reads it

  return {
    flags,
    reset() { flags.submitted = false; flags.fields = {}; onChange(flags); },
    destroy() {
      form.removeEventListener("focusin", onFocusIn);
      form.removeEventListener("focusout", onFocusOut);
      form.removeEventListener("submit", onSubmit, true);
    },
  };
}

// The single rule the whole UI uses. Dirty is deliberately absent.
export function shouldShowError(name: string, flags: FormFlags, hasError: boolean): boolean {
  return hasError && (flags.submitted || flags.fields[name]?.touched === true);
}

Dirty is not tracked here because it is not an interaction; compute it from values, as in deep equality for dirty detection.


Step-by-step walkthrough

  1. Listen for focusin and focusout on the form. They bubble, so one pair of listeners covers every field and survives conditional rendering.
  2. Set visited on the first focus and touched on the first real exit. Ignore focus moving between members of the same radio or checkbox group, otherwise arrowing through a group marks it touched before the user has chosen.
  3. Set submitted in the capture phase of submit. Validation and error rendering then see the flag during the same submit.
  4. Gate error display on touched || submitted. One function, used everywhere, so no component invents its own rule.
  5. Keep dirty for the unsaved-changes guard and the save button. It is the only flag that goes back to false when the user undoes their edit, which is exactly what those two features need.
  6. Clear all flags on reset and after a successful save. A saved form should look fresh: no lingering touched errors on fields the next edit has not reached.
A keyboard user tabbing past a required field When focus enters the empty required email field, visited becomes true and no error is shown. When the user tabs away without typing, touched becomes true while dirty stays false; with the touched rule the required error now appears, whereas a dirty rule would show nothing. When the user presses submit, submitted becomes true and every invalid field shows its error regardless of flags. Tab into email (empty, required) visited = true No error yet. The user has not had a chance to answer. Tab Tab out without typing touched = true, dirty = false touched rule: required error shows now. dirty rule: nothing shows, and the user moves on unaware. Enter Press submit submitted = true Every invalid field shows its message, touched or not.

Failure modes and edge cases

1. Errors that flash on focus

Setting touched on focus instead of blur shows a required error the instant the user enters an empty field. Use focusout, and pair it with the timing rules from reward early, punish late so an error, once shown, clears as soon as the value becomes valid.

2. Autofill makes fields dirty but not touched

Autofill changes values without focus. That is correct: the fields are dirty (worth saving) but not touched (the user has not been asked about them). If autofilled data is invalid, errors appear on submit, not before — which is the right time. See handling browser autofill in controlled inputs.

3. Custom widgets that never blur

A date picker that opens a popup moves focus into the popup and back; a naive listener marks the field touched when focus enters the popup. Treat focus moving to an element inside the widget’s container as “not leaving” by checking container.contains(e.relatedTarget).

4. Clicking the submit button counts as blur

Pressing submit blurs the last field, setting it touched a moment before submitted. That is harmless because both flags lead to the same display, but it matters if you animate errors: batch the two state changes so the field’s message does not animate twice.

5. Window blur

Switching browser tabs fires focusout with relatedTarget === null. If you do not want tab-switching to mark the field touched, ignore focusout when document.hasFocus() is false at the next frame.

Which flag each feature should read Error display reads touched or submitted. The unsaved-changes guard reads dirty, because it must go quiet if the user undoes their edit. Save button enablement reads dirty and valid. Abandonment analytics reads visited without touched, meaning the user entered a field and left the page before leaving it. Error display touched || submitted Unsaved guard dirty Quiet again after an undo. Save button dirty && valid Analytics visited && !touched Entered, never left.

Verification checklist


Frequently Asked Questions

Is touched the same as React Hook Form's touchedFields?

Yes in meaning: React Hook Form marks a field touched on blur by default. Its dirtyFields is the comparison-based flag. Formik’s touched is also blur-based. The names are consistent across libraries because the underlying questions are.

Why keep visited at all?

It is the only flag that captures “focused but never left”, which is useful for analytics about where users abandon a form, and for showing contextual hints on first focus without waiting for blur. If you need neither, drop it.

Should programmatic value changes set touched?

No. Touched records that the user has been asked about a field. A value filled by code — a default, a computed field, a restored draft — should affect dirty, never touched, so its errors wait for the user’s attention or a submit.


Related

← Dirty and Pristine State Tracking