patterntypescriptreactMajor
Uncontrolled edit form: keep untouched fields in sync with stored values (select, checkbox after hydration, back-forward restore)
Viewed 0 times
React 19, Next.js 16 App Router, Chromium-based browsers
defaultSelecteddefaultCheckeddirty checkednessuncontrolled selectstale form valueform state restorationautocomplete offuseActionState
Error Messages
Problem
A server-rendered edit form with uncontrolled fields (defaultValue/defaultChecked from the database, a hidden "original value" per field so only changed fields are written) shows stale values after a refresh or a write from another tab, so the next save of another field silently rewrites the stale value. Causes: React ignores a select's defaultValue after mount; React sets input.checked = input.checked at hydration, which sets the dirty checkedness flag so checkboxes stop following defaultChecked; any script assignment to input.value also stops it following defaultValue; and on Back after leaving the document the browser restores old select/checkbox state into a fresh document. Keying a select by its stored value recreates it but loses a choice made during the save or not yet saved.
Solution
Make the form client component own the invariant: a field the user has not changed always shows its stored value; a changed field keeps its input until saved. 1) Record which fields the user changed with onInput/onChange on the form (key: name, plus value for checkboxes sharing a name), and separately those changed while a save is in flight. 2) After every render (useLayoutEffect without deps), for each non-hidden control: if it shows its default, drop it from the changed set; else if not changed, set it to its default. 3) On a successful save, the changed set becomes only the fields changed during the save; on refusal keep all. 4) Wrap select in a small client component that, in a layout effect on defaultValue, sets option.defaultSelected to the new stored value and then restores el.value (setting defaultSelected on a non-dirty option moves the selection). 5) autoComplete="off" on the form so the browser does not restore old state on back navigation. Compare like the browser: strip CR/LF from a one-line input's default, normalize CRLF to LF for textarea. Keep the logic in pure functions over duck-typed {name,type,value,defaultValue,checked,defaultChecked,options} so it is testable in node with fake objects.
Why
Uncontrolled fields only follow their defaults while the browser's dirty flags are false, and React itself flips those flags (checkbox at hydration, value when it creates an element client-side) and never updates a select's default selection. Value-snapshot diffing to detect "changed during save" also misfires, because React updating defaultValue in the same commit changes untouched fields too.
Gotchas
- Do not detect user changes by comparing values before/after a save: untouched fields change too when React applies new defaults, and re-assigning them marks them dirty forever.
- A key on a select (key={storedValue}) loses the user's pending choice and focus whenever the stored value changes.
- form.reset() relies on defaultSelected, which React does not update for selects.
- Catch only TypeError from the action call for network loss (fetch rejection); framework redirect errors must be rethrown.
- Give the error message element a key that changes on each result so screen readers re-announce identical refusals.
Code Snippets
Resync after each render
export function settle(fields: Field[], edited: Set<string>) {
for (const el of fields) {
const key = keyOf(el);
if (shown(el) === stored(el)) edited.delete(key);
else if (!edited.has(key)) show(el, stored(el));
}
}Context
Server-rendered record edit form with uncontrolled fields and a hidden original value per field (only changed fields are written), refreshed by server actions or by writes from another tab.
Revisions (0)
No revisions yet.