Tracking Dirty, Touched and Pristine State

When should a field show its error — immediately, after the user leaves it, or only after a submit attempt? Should “Save” be enabled if nothing changed? Should navigating away warn about unsaved edits? All three questions depend on per-field interaction state that every form library tracks under names like touched, dirty and pristine, and that plain forms usually reinvent with ad hoc flags. This recipe tracks all three in vanilla TypeScript with a WeakMap keyed by element, uses them to time error display on top of the Constraint Validation API, compares against the initial value so undoing an edit makes a field pristine again, and derives form-level “has unsaved changes” and “changed fields only” from the same data.

When to Track Field Interaction State

Track it whenever error timing or change detection matters — which is most forms beyond a single search box. It is the right tool when:

  • Errors should wait for the user. Showing “Enter an email” before the user has even focused the field is noise; showing it after they leave it is help. That needs touched.
  • Edits must be detected. Enabling Save, warning on navigation, or sending a PATCH with only changed fields needs dirty.
  • Undo should count. A user who changes a value and changes it back has no unsaved changes — which needs dirty computed against the initial value, not “was ever edited”.

Framework libraries expose the same concepts: formState.touchedFields and dirtyFields in React Hook Form, meta.touched and meta.dirty in VeeValidate, touched/dirty/pristine on Angular controls. The definitions below match theirs, so the reasoning transfers; the form state machines topic covers the form-level lifecycle these field states feed into.

Field interaction states defined Cards defining touched, untouched, dirty, pristine, submitted and the derived "show error" rule for a form field. Untouched the field has never lost focus Touched the field has lost focus at least once Pristine current value equals the initial value Dirty current value differs from the initial value Submitted the form has had a submit attempt Show error when invalid AND (touched OR submitted)
Touched is about attention, dirty is about value; showing an error usually needs touched or a submit attempt, never just invalidity.

Minimal Working Field-State Tracker

type Control = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;

interface FieldMeta { initial: string; touched: boolean }

/** A comparable snapshot of a control's value (checkboxes and multi-selects included). */
function valueOf(el: Control): string {
  if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) return String(el.checked);
  if (el instanceof HTMLSelectElement && el.multiple) return [...el.selectedOptions].map((o) => o.value).join("\0");
  if (el instanceof HTMLInputElement && el.type === "file") return [...(el.files ?? [])].map((f) => `${f.name}:${f.size}`).join("|");
  return el.value;
}

export function trackFieldState(form: HTMLFormElement) {
  const meta = new WeakMap<Control, FieldMeta>();
  let submitted = false;
  const controls = () => [...form.elements].filter((e): e is Control => "validity" in e && !!(e as Control).name);

  const snapshot = () => controls().forEach((el) => meta.set(el, { initial: valueOf(el), touched: false }));
  snapshot();

  const isDirty = (el: Control) => valueOf(el) !== (meta.get(el)?.initial ?? "");
  const isTouched = (el: Control) => meta.get(el)?.touched ?? false;
  const shouldShowError = (el: Control) => !el.validity.valid && (isTouched(el) || submitted);

  const paint = (el: Control) => {
    el.toggleAttribute("data-dirty", isDirty(el));
    el.toggleAttribute("data-touched", isTouched(el));
    const show = shouldShowError(el);
    el.setAttribute("aria-invalid", String(show));
    const out = document.getElementById(`${el.id}-err`);
    if (out) { out.textContent = show ? el.validationMessage : ""; out.hidden = !show; }
  };

  // focusout bubbles (blur does not), so one listener covers every field.
  form.addEventListener("focusout", (e) => {
    const el = e.target as Control;
    const m = meta.get(el);
    if (!m) return;
    m.touched = true;
    paint(el);
  });
  form.addEventListener("input", (e) => {
    const el = e.target as Control;
    if (!meta.has(el)) meta.set(el, { initial: "", touched: false });   // dynamically added field
    paint(el);
    form.toggleAttribute("data-dirty", controls().some(isDirty));
  });
  form.addEventListener("submit", (e) => {
    submitted = true;
    controls().forEach(paint);
    if (!form.checkValidity()) {
      e.preventDefault();
      form.reportValidity();      // canonical: focus + announce the first invalid field
    }
  });

  return {
    isDirty: () => controls().some(isDirty),
    changedFields: () => Object.fromEntries(controls().filter(isDirty).map((el) => [el.name, valueOf(el)])),
    markSaved: () => { submitted = false; snapshot(); controls().forEach(paint); form.removeAttribute("data-dirty"); },
    reset: () => { form.reset(); submitted = false; snapshot(); controls().forEach(paint); },
  };
}

Three design decisions make this robust. Dirty compares the current value with a stored initial value, so undoing an edit returns the field to pristine. Touched is set on focusout, which bubbles, so one delegated listener covers fields added later — the technique from validating on blur versus on input. And a submit attempt sets submitted, which reveals errors on untouched fields — otherwise a user who clicks Save on an empty form would see nothing.

When a field's error becomes visible A timeline for one email field showing typing while untouched with no error shown, blur making the field touched and revealing the error, and further typing clearing it once valid. Validity invalid valid Touched blur → touched Error shown shown 0 s 2 s 4 s 6 s 8 s 10 s
The value is invalid from the first keystroke, but the error only appears once the field is touched, and it disappears as soon as the value becomes valid.

Field-State Option Reference

Item Type Default Purpose
initial string snapshot value at load Baseline for dirty detection
touched boolean false Set on first focusout
submitted boolean false Reveals errors on untouched fields
shouldShowError predicate invalid && (touched || submitted) Timing rule for visible errors
data-dirty / data-touched attributes CSS hooks for styling changed or visited fields
changedFields() () => Record<string, string> Payload for PATCH requests
markSaved() () => void Resets the baseline after a successful save

Styling hooks keep CSS declarative: [data-dirty] can show a subtle “edited” marker, and [aria-invalid="true"] shows the error border only when the timing rule allows — a portable alternative to :user-invalid, discussed in styling invalid inputs with :user-invalid.

Verification Steps

import { test, expect } from "@playwright/test";

test("errors wait for touch or submit", async ({ page }) => {
  await page.goto("/profile");
  const email = page.getByLabel("Email address");
  await email.fill("not-an-email");
  await expect(email).toHaveAttribute("aria-invalid", "false");     // untouched: hidden
  await email.blur();
  await expect(email).toHaveAttribute("aria-invalid", "true");      // touched: shown
  await email.fill("ada@example.com");
  await expect(email).toHaveAttribute("aria-invalid", "false");     // valid: cleared live
});

Edge Cases and Failure Modes

Autofill and dirty state. Browser autofill changes values without the user typing. Whether autofilled fields count as dirty is a product choice; the tracker treats them as dirty because the value differs from the initial one, which is usually right for “unsaved changes” warnings.

Server-populated initial values arriving late. If the form is filled from an API after load, snapshot after filling, or every field starts dirty. Call markSaved() once the data has been applied.

Dynamically added rows. New fields have no snapshot. The tracker records them on first input with an empty initial value, so a new row counts as dirty — correct for “unsaved changes”, and it keeps working with validating dynamically added form rows.

Touched on programmatic focus. Moving focus with script (for example after a server error) and then away sets touched even though the user did nothing. That is acceptable for error timing; if it matters, set a flag while moving focus programmatically and ignore the next focusout.

Comparing Values Correctly

Dirty detection is only as good as its comparison. Three details trip up most implementations. Whitespace: a user who adds a trailing space has technically changed the value, but most products do not want that to count; compare trimmed values for text fields where the server trims anyway. Numbers: "1.0" and "1" differ as strings but not as quantities; compare parsed numbers for numeric fields. And ordering: a multi-select whose options were chosen in a different order is not changed; sort selected values before joining them, as valueOf effectively does by reading selectedOptions in document order. Encode these choices in valueOf, per field type, so every consumer — the Save button, the navigation warning, the PATCH payload — agrees on what “changed” means.

Warning About Unsaved Changes

Dirty tracking enables the “you have unsaved changes” warning, which prevents data loss when a user closes a tab or follows a link mid-edit. Use the platform’s beforeunload prompt for tab closes and reloads — browsers show their own generic text and ignore custom messages — and an in-page confirmation for in-app navigation. Only register the beforeunload handler while the form is dirty, because its mere presence disables the back-forward cache in some browsers, slowing navigation for every user.

const state = trackFieldState(form);
const warn = (e: BeforeUnloadEvent) => { e.preventDefault(); };
form.addEventListener("input", () => {
  if (state.isDirty()) window.addEventListener("beforeunload", warn);
  else window.removeEventListener("beforeunload", warn);
});
form.addEventListener("submit", () => window.removeEventListener("beforeunload", warn));

The same data drives a lean PATCH: send state.changedFields() instead of the whole form, so the server only validates and writes what the user actually changed. The server must still validate the changed fields against the full record, since a single field can break a cross-field rule, as covered in cross-field validation strategies.

A field's dirty lifecycle A field starts pristine, becomes dirty when its value differs from the initial value, returns to pristine when the edit is undone, and becomes pristine with a new baseline after a successful save. pristine dirty saved (new baseline) value ≠ initial value = initial again successful save edit after save
Dirty is a comparison, not a history: undoing an edit makes the field pristine again, and saving moves the baseline.

Frequently Asked Questions

What is the difference between touched and dirty?

Touched means the field has lost focus at least once, which tells you the user has visited it. Dirty means its current value differs from its initial value, which tells you it has unsaved changes. A field can be touched but pristine, or dirty but untouched.

When should a field's validation error be shown?

A common rule is when the field is invalid and either touched or the form has had a submit attempt. That avoids errors before the user has had a chance, while making sure a submit on an empty form reveals everything.

How do I detect unsaved form changes in JavaScript?

Snapshot each control's value on load, compare the current value to the snapshot on input, and treat the form as dirty if any field differs. Comparing against the snapshot makes undoing an edit count as no change.

Why use focusout instead of blur to track touched fields?

focusout bubbles, so one listener on the form covers every field, including ones added later. blur does not bubble and needs a listener on each field.

← Back to Form State Machines