A form that reports itself dirty when the user has changed nothing is almost always comparing representations rather than meanings: a Date against an ISO string, undefined against "", 1 against "1", or two arrays holding the same tags in a different order.

The overall model — a baseline snapshot plus a comparison — is set out in dirty and pristine state tracking. This page is about the comparison itself once values stop being flat strings: address objects, tag lists, date pickers and nested repeatable groups. JSON.stringify(a) === JSON.stringify(b) is the usual first attempt, and it fails on four of the five cases below.


Context and prerequisites

Dirty detection answers “would saving now change anything?” That is a question about meaning, and form values routinely carry the same meaning in different shapes:

  • The server sends "2026-03-01T00:00:00Z"; the date picker holds a Date.
  • The server omits middleName; the input’s empty state is "".
  • The server sends quantity: 1; a text input yields "1".
  • The server sends tags: ["a", "b"]; the user removes and re-adds a, producing ["b", "a"] for a field where order is irrelevant.
  • The server sends { city, street }; your state builds { street, city }.

The reliable approach is to normalise both sides into a canonical form, then compare structurally, with the normalisation rules declared per field rather than guessed globally.

Same meaning, different shape A Date object against the same instant as an ISO string is reported dirty by stringify but clean after normalising both to epoch milliseconds. Undefined against an empty string is dirty by stringify but clean after treating both as empty. The number one against the string one is dirty by stringify but clean after coercing by field type. Two arrays of the same tags in different order are dirty by stringify but clean when the field is declared as a set. Objects with the same keys in a different order are dirty by stringify but clean with a structural comparison. Case Baseline Current stringify says Normalised says Date vs string Date(2026-03-01) "2026-03-01T00:00Z" dirty clean missing vs empty undefined "" dirty clean number vs text 1 "1" dirty clean set order [a, b] [b, a] dirty clean key order {city, street} {street, city} dirty clean Key order also breaks stringify in practice, because spreading or rebuilding an object can reorder its keys.

The core pattern: a declared normaliser plus a structural compare

type Kind = "text" | "number" | "date" | "set" | "list" | "object";
export type FieldSpec = { kind: Kind; fields?: Record<string, FieldSpec>; item?: FieldSpec };

// Canonicalise one value according to its declared kind. Both sides of the
// comparison go through this, so the comparator never sees raw shapes.
export function normalise(v: unknown, spec: FieldSpec): unknown {
  switch (spec.kind) {
    case "text":
      // undefined, null and "" all mean "empty"; trim trailing whitespace only
      // if your product treats it as meaningless (most do).
      return v == null ? "" : String(v).replace(/\s+$/, "");
    case "number": {
      if (v === "" || v == null) return null;
      const n = typeof v === "number" ? v : Number(v);
      return Number.isNaN(n) ? String(v) : n;   // keep unparsable text so it still differs
    }
    case "date": {
      if (v === "" || v == null) return null;
      const t = v instanceof Date ? v.getTime() : Date.parse(String(v));
      return Number.isNaN(t) ? String(v) : t;   // compare instants, not formats
    }
    case "set": {
      const arr = Array.isArray(v) ? v : [];
      // Order is meaningless: normalise items, then sort by a stable key.
      return arr.map((x) => normalise(x, spec.item ?? { kind: "text" }))
                .map((x) => JSON.stringify(x)).sort();
    }
    case "list": {
      const arr = Array.isArray(v) ? v : [];
      return arr.map((x) => normalise(x, spec.item ?? { kind: "text" }));  // order matters
    }
    case "object": {
      const src = (v ?? {}) as Record<string, unknown>;
      const out: Record<string, unknown> = {};
      // Iterate the SPEC's keys, not the value's: unknown extra keys are ignored
      // and key order becomes irrelevant.
      for (const k of Object.keys(spec.fields ?? {}).sort()) out[k] = normalise(src[k], spec.fields![k]);
      return out;
    }
  }
}

export function deepEqual(a: unknown, b: unknown): boolean {
  if (Object.is(a, b)) return true;
  if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
  if (Array.isArray(a) !== Array.isArray(b)) return false;
  const ka = Object.keys(a), kb = Object.keys(b);
  if (ka.length !== kb.length) return false;
  return ka.every((k) => deepEqual((a as any)[k], (b as any)[k]));
}

// Per-path dirty map: tells you WHICH leaf changed, for badges and patch payloads.
export function dirtyPaths(base: unknown, cur: unknown, spec: FieldSpec, path = ""): string[] {
  if (spec.kind === "object") {
    return Object.keys(spec.fields ?? {}).flatMap((k) =>
      dirtyPaths((base as any)?.[k], (cur as any)?.[k], spec.fields![k], path ? `${path}.${k}` : k));
  }
  return deepEqual(normalise(base, spec), normalise(cur, spec)) ? [] : [path];
}

Normalise the baseline once when you capture it and cache the result; normalise the current value on each check. Even a full 400-field comparison costs well under a millisecond, and scoping the check to the edited path makes it effectively free.


Step-by-step walkthrough

  1. Declare a spec for every field. The spec states what a value means — a date instant, a set, an ordered list — which is information the value itself does not carry.
  2. Normalise both sides through the same function. The comparator then only ever sees canonical shapes: numbers, epoch milliseconds, sorted arrays, objects with spec-ordered keys.
  3. Compare structurally with Object.is at the leaves. Object.is treats NaN as equal to itself, which matters because an unparsable number normalises to its original string and must still compare consistently.
  4. Report dirty paths, not just a boolean. A list of changed paths drives per-field dirty badges, the unsaved-changes guard in warning before leaving a form, and a PATCH body containing only what changed.
  5. Rebase the normalised baseline after a save. When the save succeeds, the saved values become the new baseline, as described in resetting the dirty baseline after a successful save.
Where normalisation sits in the dirty check The baseline is normalised once when captured and cached. The current value is normalised on each check using the same field spec. Both canonical forms go into a structural compare that uses Object.is at the leaves. The output is a list of dirty paths, from which the boolean isDirty, per-field badges and a minimal patch payload are all derived. Raw values Baseline from the server. Current from the inputs. Normalise Same spec, both sides. Baseline result is cached. Structural compare Object.is at leaves. Spec-ordered keys. Dirty paths isDirty = paths.length > 0 Badges and PATCH body.

Failure modes and edge cases

1. Time zones in date-only fields

A date-only field (“date of birth”) compared as an instant flips dirty when the baseline was serialised in UTC and the picker produces local midnight. For date-only values normalise to the calendar string YYYY-MM-DD, not to milliseconds; validating dates across time zones explains the distinction.

2. Trimming that changes meaning

Stripping trailing whitespace is safe for names and emails; it is not safe for a free-text field where a user deliberately added a trailing newline, or a password. Make trimming a per-field option rather than baking it into text.

3. Sets of objects

Sorting a set of { id, label } objects by their stringified form works only if the objects are themselves normalised first — which the code does by normalising items before stringifying. Skip that step and key order inside each item reintroduces the bug.

4. Floating-point display rounding

If a number input displays 0.3 but the baseline is 0.30000000000000004, the compare reports dirty forever. Round to the field’s declared precision during normalisation for decimal fields.

5. Comparing on every keystroke in very large forms

Full-form normalisation per keystroke is cheap in isolation but adds up once it runs alongside rendering and validation for every keystroke in a form of several hundred fields. Compute dirtyPaths only for the edited path and keep a Set of dirty paths, adding and removing as each field changes; the approach mirrors the subscription isolation in performance and scale for large forms.

Cost of a dirty check per keystroke Measured in Node 22 on a 400 field object mixing text, number and date fields, averaged over two thousand runs. Stringifying both sides took about 111 microseconds and still reported the form dirty because of the Date and number-versus-string fields. A full normalise and structural compare took about 87 microseconds and reported it clean. Normalising and comparing only the edited field took about 0.02 microseconds. stringify both sides 111 µs, wrong full normalise + compare 87 µs per-path incremental 0.02 µs Node 22, 400 fields, mean of 2,000 runs. Correct comparison is not the expensive part; comparing the whole form on every keystroke is.

Verification checklist


Frequently Asked Questions

Can I use lodash isEqual instead of writing a comparator?

isEqual is a fine structural comparator, but it compares shapes as they are: Date against string, 1 against "1" and reordered sets are all unequal to it. You still need the normalisation step; isEqual can replace only the deepEqual function.

Should empty string and undefined really count as equal?

For text inputs, yes: an empty input cannot express the difference, so treating them as different creates dirty flags the user cannot resolve. Where the distinction matters to the API — “clear this field” versus “leave it alone” — make it explicit in the payload, not in dirty detection.

Where do I get the field spec from if I already have a Zod schema?

Derive it by walking the schema: z.date() maps to date, z.number() to number, arrays to list unless you mark them as sets, objects to object. Keep the set-versus-list decision explicit, because no schema type encodes whether order matters.


Related

← Dirty and Pristine State Tracking