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 defaultValue property 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.

Three initial values, one of which the browser owns The DOM default is captured at element creation and only changes on remount or when you assign defaultValue directly. The form baseline is a snapshot in your state and changes only when your code rebases it. The server record changes whenever a fetch or push arrives. A stale-field bug is any moment where these three disagree and the reader cannot tell. What moves when the record changes DOM default Captured when the element is created. A new defaultValue prop after mount changes the attribute, not the visible value. Moves on: remount, or explicit el.defaultValue = x. Form baseline Your snapshot for dirty comparison and reset. Moves only when your code rebases it. Stale baseline = fields flagged dirty that the user never touched. Server record Whatever the latest fetch or push returned. Moves on its own schedule, often after first paint. The only one of the three you do not control. The fix is always the same shape: pick one event that moves all three together, and make it explicit.

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

  1. 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.
  2. Write the re-seed decision as a pure function. decideReseed takes 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.
  3. Re-seed by remounting, not by patching values. A key change recreates every input, so each one re-reads its defaultValue, and form.reset() afterwards restores the new values rather than the old ones.
  4. Move the baseline in the same step. Setting seededFrom and clearing the dirty flag inside the same branch that bumps the key keeps the DOM default and your comparison snapshot aligned.
  5. 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.

A late fetch with and without a record-keyed seed The form first mounts from a cached record at version 3. The network fetch then returns version 4. The seed hook compares ids and versions, sees the form is not dirty, bumps the seed key and remounts the form, so the inputs read the version 4 defaults. Later a background refetch returns version 4 again and the hook ignores it. Finally a version 5 push arrives while the user is typing; the hook reports a conflict and shows a prompt instead of remounting. Server / cache Seed hook Form DOM cached record v3 arrives first mount with key 1, defaults from v3 fetch resolves: v4 not dirty: bump key to 2, inputs re-read v4 refetch returns v4 again push: v5 while user types dirty: show prompt, keep typed values The refetch that repeats v4 is ignored because the version did not increase — without that check every focus-refetch would remount the form.

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.

Which re-seed technique fits which form A keyed remount updates the DOM default, the value and the baseline together but loses focus and any component-local state. Direct DOM assignment keeps focus but must set both defaultValue and value on every field, and your baseline separately. A library reset call updates the library's values and its baseline but not the native DOM default, so a native form.reset afterwards will disagree. Use remount for uncontrolled forms that are not focused, direct assignment for background updates on a visible form, and the library call for controlled forms. Technique Updates Focus Use it when Keyed remount default, value and baseline in one step lost; restore it by field name Uncontrolled form, record swapped before editing starts Direct DOM assignment value and default per field; baseline by hand kept A visible form receiving a background update Library reset(values) library values and baseline, not the native default kept Controlled form where the library owns values Mixing techniques is where drift comes from: a library reset followed by a native form.reset() restores the values from first mount.

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.


Related

← Controlled vs Uncontrolled Forms