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 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
- Listen for
focusinandfocusouton the form. They bubble, so one pair of listeners covers every field and survives conditional rendering. - Set
visitedon the first focus andtouchedon 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. - Set
submittedin the capture phase of submit. Validation and error rendering then see the flag during the same submit. - Gate error display on
touched || submitted. One function, used everywhere, so no component invents its own rule. - Keep
dirtyfor 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. - 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.
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.
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.