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 aDate. - 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-addsa, 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.
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
- 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.
- 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.
- Compare structurally with
Object.isat the leaves.Object.istreatsNaNas equal to itself, which matters because an unparsable number normalises to its original string and must still compare consistently. - 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.
- 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.
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.
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.