The exact problem: a controlled React form marks every field as dirty the moment async-loaded default values arrive, because the baseline was set before the server data resolved.

This page shows how to capture a stable baseline snapshot in a ref, derive a per-field dirty map without extra state, and expose a syncBaseline callback that makes async hydration invisible to the dirty-detection logic — all without triggering unnecessary re-renders.

Context and Prerequisites

This pattern builds on dirty and pristine state tracking — the distinction between a user-driven mutation and a programmatic reset. Before reading further, make sure you understand controlled vs uncontrolled forms because the hook here assumes fully controlled inputs where React owns every field value.

How the Baseline-Ref Pattern Works

The diagram below shows data flow through the hook at mount time and after async hydration.

useDirtyTracker data flow At mount, initialValues flows into both useRef (baseline) and useState (current). On each updateField call, only useState updates, and useMemo recomputes the dirtyMap by comparing current against baseline.current. When syncBaseline is called after async data resolves, it atomically writes the new values to both the ref and useState, so dirtyMap collapses back to all-false. initialValues prop / empty shape useRef baseline.current useState current — triggers renders useMemo derived, not stored syncBaseline(newValues) writes ref + setCurrent atomically updateField(key, value) only setState, never baseline

The key insight: useRef holds the baseline and mutating it never triggers a re-render. useMemo reads baseline.current as a captured value inside its factory, so the dirty map is always fresh without being stored in state.

Core Hook Implementation

import { useState, useRef, useMemo, useCallback } from 'react';

/**
 * Tracks field-level mutations against an immutable baseline snapshot.
 *
 * Design choices:
 *  - baseline lives in useRef: mutating it is synchronous and never
 *    schedules a render, so syncBaseline cannot create a stale-closure window.
 *  - dirtyMap is derived via useMemo: no separate useState means no
 *    redundant update cycle when current changes.
 *  - T extends Record<string, unknown> keeps the generic open enough
 *    for date objects, File instances, and nullable fields.
 */
export function useDirtyTracker<T extends Record<string, unknown>>(
  initialValues: T
) {
  // Ref holds the pristine baseline — mutations here are invisible to React.
  const baseline = useRef<T>(initialValues);

  // useState holds current user-edited values and drives renders.
  const [current, setCurrent] = useState<T>(initialValues);

  // Derive dirty state; only recalculates when `current` object reference changes.
  const dirtyMap = useMemo(() => {
    return Object.keys(current).reduce<Record<string, boolean>>((acc, key) => {
      // Strict equality handles primitives, null, and undefined correctly.
      // For fields holding objects or arrays, replace !== with a deep-equal
      // call or JSON.stringify(a) !== JSON.stringify(b) — but do this only
      // for the affected keys, not globally, to keep O(n) complexity bounded.
      acc[key] = current[key] !== baseline.current[key];
      return acc;
    }, {});
  }, [current]);

  // Route all field updates through here so the hook owns the mutation path.
  const updateField = useCallback(
    (key: keyof T, value: T[keyof T]) => {
      setCurrent(prev => ({ ...prev, [key]: value }));
    },
    [] // setCurrent is stable; no deps needed
  );

  /**
   * Call this when async server data resolves.
   * Sets baseline.current BEFORE calling setCurrent so that the
   * immediately-triggered useMemo sees a matching baseline and
   * produces an all-false dirtyMap — no flash of "everything is dirty".
   */
  const syncBaseline = useCallback((newValues: T) => {
    baseline.current = newValues;  // synchronous — happens before next render
    setCurrent(newValues);
  }, []);

  // Discard all user edits and return to the current baseline.
  const resetToPristine = useCallback(() => {
    setCurrent(baseline.current);
  }, []);

  const isDirty = useMemo(
    () => Object.values(dirtyMap).some(Boolean),
    [dirtyMap]
  );

  return { current, dirtyMap, isDirty, updateField, syncBaseline, resetToPristine };
}

Step-by-Step Walkthrough

  1. Mount with placeholder values. Pass an empty shape ({ email: '', password: '' }) as initialValues. Both baseline.current and current start identical, so dirtyMap is all-false immediately — no field appears dirty before the user touches anything.

  2. Async hydration resolves. Call syncBaseline(serverData). The ref update is synchronous, so when setCurrent fires and React schedules a render, useMemo’s factory already reads the updated baseline.current. The resulting dirtyMap is still all-false. Without this atomic ordering, the render between setCurrent and a deferred baseline update would show every field as dirty for one frame.

Ordering is the whole trick, and it is easier to see as two timelines of the same fetch:

Deferred baseline versus ref-first hydration Top row, deferred baseline: mount with an empty baseline and empty current; server data arrives and setCurrent runs while the baseline is still empty, so the dirty map marks every field; a later effect finally updates the baseline; the reader has already seen a frame of unsaved-changes badges. Bottom row, ref-first: mount identically; the server data is written straight to baseline.current, which is synchronous and schedules no render; setCurrent then triggers one render in which useMemo already reads the new baseline; the reader sees the saved values with no badges. Deferred baseline — one render shows every field dirty 1 · mount baseline = {} (empty) current = {} (empty) dirty: none 2 · data arrives setCurrent(server) baseline still empty dirty: all 3 · effect fires baseline = server one render too late dirty: none 4 · reader sees a flash of unsaved badges on every row regression Ref-first — same data, same fetch, no dirty frame 1 · mount baseline = {} (empty) current = {} (empty) dirty: none 2 · data arrives baseline.current = server — no render sync write 3 · setCurrent useMemo reads the new baseline dirty: none 4 · reader sees the saved values, no badges anywhere clean The only difference is that the baseline write is synchronous, so it lands before React renders — not after.
  1. User edits a field. The onChange handler calls updateField('email', e.target.value). Only current changes; baseline.current is untouched. useMemo recomputes and marks dirtyMap.email = true.

  2. Conditional UI responds. Read isDirty to enable a save button, or read dirtyMap.fieldName to show a per-field “unsaved” indicator via the data-dirty attribute.

  3. User cancels. Call resetToPristine() to snap current back to baseline.current. dirtyMap collapses to all-false on the next render.

Usage: Form with Async Hydration

import { useEffect } from 'react';
import { useDirtyTracker } from './useDirtyTracker';

interface ProfileFields {
  email: string;
  displayName: string;
}

export function ProfileForm() {
  const {
    current,
    dirtyMap,
    isDirty,
    updateField,
    syncBaseline,
    resetToPristine,
  } = useDirtyTracker<ProfileFields>({ email: '', displayName: '' });

  // Establish the real pristine baseline once server data arrives.
  useEffect(() => {
    async function fetchProfile() {
      const profile = await fetch('/api/me').then(r => r.json()) as ProfileFields;
      syncBaseline(profile); // atomic: baseline ref then setCurrent
    }
    void fetchProfile();
  }, [syncBaseline]);

  // Dev-only audit: log which fields changed and when.
  useEffect(() => {
    if (process.env.NODE_ENV === 'development' && isDirty) {
      const changedKeys = Object.entries(dirtyMap)
        .filter(([, d]) => d)
        .map(([k]) => k);
      console.debug('[DirtyTracker] Modified fields:', changedKeys);
    }
  }, [dirtyMap, isDirty]);

  return (
    <form onSubmit={e => e.preventDefault()}>
      <label>
        Email
        <input
          type="email"
          value={current.email}
          onChange={e => updateField('email', e.target.value)}
          data-dirty={dirtyMap.email ? 'true' : 'false'}
          aria-label="Email address"
        />
      </label>
      <label>
        Display name
        <input
          type="text"
          value={current.displayName}
          onChange={e => updateField('displayName', e.target.value)}
          data-dirty={dirtyMap.displayName ? 'true' : 'false'}
          aria-label="Display name"
        />
      </label>
      <button type="submit" disabled={!isDirty}>
        {isDirty ? 'Save Changes' : 'No Changes'}
      </button>
      <button type="button" onClick={resetToPristine} disabled={!isDirty}>
        Discard
      </button>
    </form>
  );
}

Failure Modes and Edge Cases

1. Object-valued fields always appear dirty

!== compares references, not structure. Which comparison a field needs depends entirely on what that field holds:

Which equality check each field shape needs Five strategies compared across field shape, cost and verdict. Strict inequality: primitives, constant cost, correct. Strict inequality on object values: objects and arrays, constant cost, always reports dirty. JSON.stringify comparison: plain serialisable objects, cost proportional to value size, key-order sensitive. Per-key deep equal: nested objects, cost proportional to value size, correct but opt in per field. Normalise then compare: dates, File objects and numeric strings, constant cost after normalising, the most reliable option for typed fields. Comparison Field shape it fits Cost per key Verdict a !== b strings, numbers, null constant correct — the default a !== b on objects objects, arrays constant always dirty JSON.stringify pair plain, serialisable size of value key-order sensitive deep equal, per key nested objects size of value correct — opt in only normalise, then !== Date, File, "007" constant most reliable

Back to the reference trap: if a field holds { x: 1 }, two separate object literals are never === even when they contain the same data.

// Fix: serialize object fields before comparing
acc[key] =
  typeof current[key] === 'object' && current[key] !== null
    ? JSON.stringify(current[key]) !== JSON.stringify(baseline.current[key])
    : current[key] !== baseline.current[key];

Apply this only to the fields you know are objects — applying it globally is unnecessary and slower.

2. Stale baseline when async data races a user edit

If the user edits a field before syncBaseline runs, syncBaseline will overwrite current and discard the edit. Gate it with a “loaded” flag:

const hasHydrated = useRef(false);

async function fetchProfile() {
  const profile = await fetch('/api/me').then(r => r.json()) as ProfileFields;
  if (!hasHydrated.current) {
    hasHydrated.current = true;
    syncBaseline(profile);
  }
}

3. Browser autofill bypasses updateField

Chrome and Firefox can autofill inputs without firing onChange. The input’s displayed value diverges from current, so the dirty map will show no change even though the UI looks different.

// Listen for the 'input' event as well, which autofill does trigger in most browsers.
<input
  type="email"
  value={current.email}
  onChange={e => updateField('email', e.target.value)}
  onInput={e => updateField('email', (e.target as HTMLInputElement).value)}
  aria-label="Email address"
/>

4. Storing dirtyMap in useState creates a render cascade

If you lift dirtyMap into its own useState, every field edit causes two state updates in sequence — one for current, one for dirtyMap — doubling render work. Keep dirty state derived, not stored.

5. Hydration mismatch from undefined initial values

If any field starts as undefined instead of an explicit empty string, React’s hydration may produce a markup mismatch between server-rendered HTML (where the input has no value attribute) and the client (where React adds one). Always provide explicit empty-string defaults for string fields.

Verification Checklist

  • dirtyMap is all-false immediately after mount (before any user interaction)
  • dirtyMap is all-false immediately after syncBaseline resolves (no dirty flash)
  • Editing a single field marks exactly that field dirty, no others
  • resetToPristine collapses dirtyMap to all-false in one render
  • Object-valued fields use structural comparison, not reference equality
  • All inputs provide aria-label or are associated with a <label>
  • data-dirty attributes are present and toggling correctly (verify in DevTools Elements panel)
  • No console.error about uncontrolled-to-controlled transition (all fields initialized with non-undefined values)
  • React DevTools Profiler shows no wasted renders on unaffected fields when one field changes

FAQ

Why use useRef for the baseline instead of useState?

Mutating a ref is synchronous and never schedules a React render. If the baseline lived in useState, resetting it would queue a render even when current hasn’t changed, doubling the update work during async hydration. The ref also lets syncBaseline update the baseline atomically — before React processes the setCurrent call — so there is no intermediate render where current has moved but baseline has not.

How does this approach handle nested form objects?

The hook defaults to shallow strict-equality (!==), which is correct for primitives. For fields that hold objects or arrays, replace the comparison for those specific keys with JSON.stringify or a dedicated deep-equal utility. Avoid applying serialization globally — it is O(n) in the size of the serialized value and will noticeably slow useMemo on forms with large embedded objects (for example, a rich-text field containing an entire document tree).

Can this hook work alongside React Hook Form or Formik?

Yes. The hook is self-contained and does not touch any library’s internal state. You can mount it in the same component as a React Hook Form useForm call and use its dirtyMap to drive auto-save logic, unsaved-changes warnings, or custom submission guards. If you are using React Hook Form, note that it already exposes formState.dirtyFields — this hook is most useful when you need dirty tracking outside React Hook Form’s controlled lifecycle, for example in a hybrid uncontrolled form where some fields are managed by refs. See building a custom useFormField hook for a pattern that composes well with this approach.

What is the performance impact on large forms?

useMemo runs in O(n) time relative to field count, but only when current changes. For forms with roughly 100 or more fields, two strategies help: (1) debounce rapid typing so setCurrent is called at most once every 150 ms rather than on every keystroke; (2) split the form into subsections with their own useDirtyTracker instances so only the relevant section’s memo runs on each update. For debouncing validation triggers the same debounce wrapper applies here.


Related

Dirty and Pristine State Tracking