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.
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
-
Mount with placeholder values. Pass an empty shape (
{ email: '', password: '' }) asinitialValues. Bothbaseline.currentandcurrentstart identical, sodirtyMapis all-false immediately — no field appears dirty before the user touches anything. -
Async hydration resolves. Call
syncBaseline(serverData). The ref update is synchronous, so whensetCurrentfires and React schedules a render,useMemo’s factory already reads the updatedbaseline.current. The resultingdirtyMapis still all-false. Without this atomic ordering, the render betweensetCurrentand a deferred baseline update would show every field as dirty for one frame. -
User edits a field. The
onChangehandler callsupdateField('email', e.target.value). Onlycurrentchanges;baseline.currentis untouched.useMemorecomputes and marksdirtyMap.email = true. -
Conditional UI responds. Read
isDirtyto enable a save button, or readdirtyMap.fieldNameto show a per-field “unsaved” indicator via thedata-dirtyattribute. -
User cancels. Call
resetToPristine()to snapcurrentback tobaseline.current.dirtyMapcollapses 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. 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
dirtyMapis all-false immediately after mount (before any user interaction)dirtyMapis all-false immediately aftersyncBaselineresolves (no dirty flash)- Editing a single field marks exactly that field dirty, no others
resetToPristinecollapsesdirtyMapto all-false in one render- Object-valued fields use structural comparison, not reference equality
- All inputs provide
aria-labelor are associated with a<label> data-dirtyattributes are present and toggling correctly (verify in DevTools Elements panel)- No
console.errorabout 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
- Implementing Pristine State in Vue 3 — equivalent pattern using Vue 3’s reactive refs and watchers
- Building a Custom useFormField Hook — composable hook architecture that this tracker plugs into
- Debouncing Validation Triggers in React — pair with dirty tracking to avoid validation on every keystroke
- Mapping Validation Errors to UI Components — wire dirty flags to error display so errors only surface on touched fields