An uncontrolled input reads defaultValue exactly once, at mount, so when the record behind a form arrives late or changes underneath it the fields keep showing the old values while your state believes the new ones are loaded.
This is the most common way the split described in controlled vs uncontrolled forms bites in production: an edit screen mounts with an empty or cached record, the fetch resolves a moment later, and nothing on screen changes. If you track edits against a baseline, the same event also corrupts dirty and pristine state tracking, because the baseline moves while the DOM does not.
Context and prerequisites
Three different “initial values” are in play on any edit form, and the bug comes from treating them as one:
- The DOM’s default — the
defaultValueproperty the browser captured when the element was created.form.reset()restores this. - Your baseline — the snapshot your form state compares against to decide what is dirty.
- The server record — what the API currently says the entity looks like.
On first render all three agree. The moment the record changes (a late fetch, a websocket push, a route change to a different entity that reuses the same component) the server record moves, and whichever of the other two you forget to move goes stale. React makes this especially easy to miss: changing the defaultValue prop on an already-mounted <input> updates the attribute but never touches the element’s current value, by design.
The core pattern: a record-keyed seed with an edit guard
The robust approach has two parts. First, derive a seed identity from the record — its id plus a version or updatedAt — and use that identity to decide when to re-seed. Second, refuse to re-seed silently while the user has unsaved edits; surface the conflict instead.
import { useEffect, useRef, useState } from "react";
interface Profile {
id: string;
version: number; // server-side optimistic-concurrency counter
displayName: string;
email: string;
}
type SeedDecision = "seed" | "ignore" | "conflict";
// Pure decision function: easy to unit test, no React in it.
export function decideReseed(
current: Profile | null,
incoming: Profile,
isDirty: boolean,
): SeedDecision {
if (!current) return "seed"; // first load
if (current.id !== incoming.id) return "seed"; // different entity: always replace
if (incoming.version <= current.version) return "ignore"; // stale or duplicate push
return isDirty ? "conflict" : "seed"; // newer data vs. local edits
}
export function useSeededForm(incoming: Profile | undefined) {
// The record the form was last seeded from. A ref, not state: changing it
// must not re-render on its own — the seedKey below drives remounts.
const seededFrom = useRef<Profile | null>(null);
const [seedKey, setSeedKey] = useState(0);
const [pending, setPending] = useState<Profile | null>(null);
const dirtyRef = useRef(false);
useEffect(() => {
if (!incoming) return;
const decision = decideReseed(seededFrom.current, incoming, dirtyRef.current);
if (decision === "seed") {
seededFrom.current = incoming;
dirtyRef.current = false;
setPending(null);
// Bumping the key remounts the <form>, so every uncontrolled input
// re-reads defaultValue. This moves DOM default AND baseline together.
setSeedKey((k) => k + 1);
} else if (decision === "conflict") {
setPending(incoming); // let the UI ask; never overwrite typing silently
}
}, [incoming]);
return {
seedKey,
defaults: seededFrom.current,
pending,
markDirty: () => { dirtyRef.current = true; },
acceptPending: () => {
if (!pending) return;
seededFrom.current = pending;
dirtyRef.current = false;
setPending(null);
setSeedKey((k) => k + 1);
},
};
}
The component renders the form with key={seedKey} and reads each input’s defaultValue from defaults. Because the key changes only when the hook decides to seed, a background refetch that returns the same version does nothing, a different entity always replaces the form, and a genuinely newer version arriving during an edit produces a visible prompt rather than a wiped field.
function ProfileForm({ profile }: { profile?: Profile }) {
const seed = useSeededForm(profile);
if (!seed.defaults) return <p role="status">Loading profile…</p>;
return (
<>
{seed.pending && (
<div role="alert">
This profile was updated elsewhere.
<button type="button" onClick={seed.acceptPending}>Load the new version</button>
</div>
)}
<form key={seed.seedKey} onInput={seed.markDirty}>
<input name="displayName" defaultValue={seed.defaults.displayName} />
<input name="email" type="email" defaultValue={seed.defaults.email} />
</form>
</>
);
}
Step-by-step walkthrough
- Give every record a seed identity. Use the entity id plus a monotonically increasing version or
updatedAt. Without a version you cannot tell a stale cache hit from a real update, and you will re-seed on every refetch. - Write the re-seed decision as a pure function.
decideReseedtakes the current seed, the incoming record and the dirty flag, and returns one of three answers. Keeping it free of framework code means the rule is testable in isolation and portable to Vue or Svelte. - Re-seed by remounting, not by patching values. A key change recreates every input, so each one re-reads its
defaultValue, andform.reset()afterwards restores the new values rather than the old ones. - Move the baseline in the same step. Setting
seededFromand clearing the dirty flag inside the same branch that bumps the key keeps the DOM default and your comparison snapshot aligned. - Turn “newer data during an edit” into a prompt. A
role="alert"banner with an explicit accept action respects the user’s typing and makes the conflict visible; the deeper merge strategies are covered in resolving conflicts when restoring a draft.
The sequence below shows the late-fetch case, which is the one most teams hit first.
Failure modes and edge cases
1. Changing defaultValue and expecting the input to follow
// BROKEN: after mount, React updates the attribute only. The field still shows
// the value the element was created with.
<input name="email" defaultValue={profile.email} />
Either key the form on the seed identity, or — if you cannot remount because focus or scroll position would be lost — assign the new default and the value directly on the element: el.defaultValue = next; el.value = next;. Assigning only value leaves form.reset() restoring the stale default.
2. Remounting on every refetch
Libraries that refetch on window focus return a new object with identical content. If your key is the object identity, or you re-seed whenever incoming changes, every tab switch wipes in-progress edits. Compare ids and versions, never references.
3. Keying on the id alone
With key={profile.id} a same-entity update never re-seeds, which is correct while the user is editing and wrong when they are not: a colleague’s change lands in the cache and the form keeps showing the old value until a full reload. The version comparison in decideReseed covers both cases.
4. Focus lost on re-seed
A remount destroys the focused element. If a re-seed can happen while the form is on screen and focused, record document.activeElement?.getAttribute("name") before bumping the key and restore focus to the same-named field in an effect afterwards, as described in restoring focus after an async submission.
5. Controlled libraries with their own reset
Form libraries that hold values in state expose a reset(values) call instead of relying on the DOM default. The same decision function applies; call reset(incoming) in the “seed” branch and skip the key entirely. Calling reset unconditionally in an effect has exactly the refetch problem from failure mode 2.
Verification checklist
Frequently Asked Questions
Why doesn't my input update when the defaultValue prop changes?
Because defaultValue only seeds the element when it is created. After mount, a changed prop updates the HTML attribute, and the browser deliberately does not copy an attribute change into a value the user may already have edited. Remount the input (usually via a key) or assign el.value and el.defaultValue yourself.
Should I just make the form controlled to avoid this?
Switching to controlled inputs moves the problem rather than removing it: you still need a rule for when incoming data replaces local state, and without one a refetch overwrites typing. The decision function on this page is the part that matters, and it works for both models.
Is it safe to use the record's updatedAt instead of a version number?
Usually, provided the server sets it and it has enough precision that two quick edits never share a timestamp. Compare it numerically after parsing, not as a string, and treat an equal or older value as a duplicate to ignore.