Remounting a Form by key Resets Uncontrolled Inputs
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Remount a form with <form key={formGen}> on every submit and inputs given only defaultValue reset with it, so a save right after a validation error stores the default, not what is on screen.
The short version
With an implementation that remounts the form on every submit, as <form key={formGen}> does, uncontrolled inputs given only defaultValue reset along with it — even on the save immediately after a validation error sent you back. What is on screen is the value you re-entered; what gets saved is the post-remount default.
What it looks like
On the ad-slot create/edit form, someone created a slot intending capacity 5 and what was actually saved was capacity 1. The audit log shows “create capacity 1 → update capacity 5 one minute later”, i.e. corrected by hand in an edit right after creation. Nothing was harmed because no bookings had come in, but the slot was published, so until it was corrected it was displayed as “1 seat left”.
This form is shared between create and edit and submits through React 19’s <form action={fn}>. The capacity input is type="number" with min={1} and no upper bound, and the DB constraint is only capacity >= 1. Two mechanisms could make capacity unintentionally small: one a known issue where the wheel over a focused number field rolls it down to min, the other the subject here — an uncontrolled input reset by the form remount.
Why
The form was designed to be rebuilt entirely, with a changing key, on every submit.
const submit = (formData: FormData) =>
(isEdit ? updateLoop(formData) : addLoop(formData)).then((r) => {
setState(r);
if (r?.ok && !isEdit) {
setSchoolId("");
setScopeType("school");
setExtraSchoolIds([]);
setMonitorIds([]);
}
setFormGen((g) => g + 1);
});
return (
<form
key={formGen}
ref={formRef}
action={submit}
...
setFormGen((g) => g + 1) sits outside r?.ok, so it always runs, whether the submit succeeded or failed. Every time key changes, React discards this <form>’s DOM along with the old tree and mounts it again as a new one. That design exists to cope with React 19’s <form action={fn}> behaviour of “resetting uncontrolled inputs to defaultValue on submit”: selections held in state such as school, scope and monitors are not subject to that automatic reset, so the key remount rebuilt the DOM to match them.
The problem was that inputs like capacity passed their initial value with defaultValue rather than value.
<input
name="capacity"
type="number"
min={1}
defaultValue={loop?.capacity ?? 10}
className={input}
/>
An input given only defaultValue is “uncontrolled” as far as React is concerned, and its current DOM value is outside React’s management. A remount is an operation that builds the DOM afresh, so an uncontrolled input starts again from defaultValue every time. Even when addLoop / updateLoop return a failure on a validation error, setFormGen runs all the same, the form remounts, and the capacity just entered on screen is gone, back at defaultValue. Since only an error message appears and the inputs are not blanked, it is hard to notice that the value has gone back to the default.
The preset button was affected through the same path.
const applyPreset = (kind: "gakka" | "shinro") => {
const f = formRef.current;
if (!f) return;
const set = (name: string, v: string) => {
const el = f.elements.namedItem(name);
if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement) el.value = v;
};
...
if (kind === "gakka") {
set("capacity", "5");
set("term_fee", "200000");
...
The preset took DOM elements straight out of formRef.current.elements and rewrote them with el.value = v. That is direct DOM manipulation bypassing React state, and it too returns to defaultValue on the next remount. Press the preset and submit immediately and nothing surfaces; save after being sent back once by a validation error, and the preset’s values are flattened to the defaults as well.
Fixing it
We changed the eight fields the preset touches — capacity, seconds, price, term and so on — from uncontrolled defaultValue inputs to controlled value + onChange.
const [plan, setPlan] = useState({
planKind: loop?.planKind ?? "",
capacity: String(loop?.capacity ?? 10),
slotSeconds: String(loop?.slotSeconds ?? 30),
termFee: num(loop?.termFee),
monthlyFee: num(loop?.monthlyFee),
termMonths: String(loop?.termMonths ?? 12),
maxTermMonths: num(loop?.maxTermMonths),
cardOnly: !!loop?.cardOnly,
});
<input
name="capacity"
type="number"
min={1}
value={plan.capacity}
onChange={(e) => setPlanField("capacity", e.target.value)}
onWheel={blurOnWheel}
className={input}
/>
Controlled values are unaffected by a remount from a changing key. What React manages is the plan state, and even when the DOM is rebuilt, value={plan.capacity} keeps referring to the same state, so a remount on a validation error preserves what was re-entered.
The preset stopped writing to the DOM and became a plain state update.
const applyPreset = (kind: "gakka" | "shinro") =>
setPlan({
...EMPTY_PLAN,
planKind: kind,
slotSeconds: "30",
termMonths: "12",
...(kind === "gakka"
? { capacity: "5", termFee: "200000", cardOnly: false }
: { capacity: "30", termFee: "10000", cardOnly: true }),
});
formRef was no longer referenced anywhere and could be deleted.
Preventing a repeat
We added a static test that pulls out each <input> tag and checks that the fields the preset touches (capacity / slot_seconds / term_fee / monthly_fee_per_monitor / term_months / max_term_months / card_only) carry no defaultValue or defaultChecked, and that applyPreset calls setPlan rather than formRef.current.elements.
it("fields the preset sets are held as controlled values (a remount does not clear them)", () => {
const PRESET_FIELDS = [
"capacity", "slot_seconds", "term_fee",
"monthly_fee_per_monitor", "term_months", "max_term_months", "card_only",
];
const form = SRC.slice(SRC.indexOf("function LoopForm("), SRC.indexOf("function LoopCard("));
const uncontrolled = inputTags(form)
.filter((t) => PRESET_FIELDS.includes(nameOf(t)))
.filter((t) => /defaultValue=|defaultChecked=/.test(t))
.map(nameOf);
expect(uncontrolled, `uncontrolled inputs that revert on remount: ${uncontrolled.join(", ")}`).toEqual([]);
});
The <input> tag extraction takes one tag as everything from <input to the next />. Inputs whose attributes hold an arrow function (onChange={(e) => ...}) contain a > in the middle, so trying to bound them with a single regex ends up missing exactly the inputs that have an onChange — the very ones under test walk straight past.
At the time we made only those eight fields controlled; the remaining inputs such as slot name and published setting were still on defaultValue. The same symptom on other fields of the same form is covered in Half-Controlled Forms Silently Revert on Resubmit.
よくある質問
Q1Why was the form remounted by key on every submit?
React 19's <form action={fn}> resets uncontrolled inputs to defaultValue on submit whether it succeeded or not, while selections held in state — school, scope, monitors — are exempt. To stop the DOM and state drifting apart, the design changed the key on every submit and rebuilt the whole form.
Q2What is wrong with remounting by key?
The remount itself does not affect inputs held in state. The problem is uncontrolled inputs given only defaultValue: they return to that value on every remount. Save right after a validation error sends you back and what is stored is the default, not what was just re-entered.
Q3What if the value came from a preset button?
This implementation's preset button rewrote the DOM by assigning directly into formRef.current.elements (el.value = ...). Being a change that bypasses React state, it returns to defaultValue on the next remount too, so unless you submit immediately the preset's values vanish.
Q4Is changing defaultValue to value enough to fix it?
Not enough. The fields have to become controlled with a value and onChange pair, and the preset has to update state rather than writing to the DOM. Fix only one and the same symptom reproduces through the other path.
Q5Can a test detect this problem?
It can. We added a static test that pulls out each <input> tag and checks that the fields the preset touches carry no defaultValue or defaultChecked, and that applyPreset calls setPlan in state rather than elements.namedItem on the DOM.
確認した環境
- Next.js 16.2.7 / React 19.2.4
- Occurred in production on 2026-07-24
この記事の根拠
- TypeScriptファイル 603〜698行目コミット 860f8fb
- TypeScriptファイル 888〜896行目コミット 860f8fb
- TypeScriptファイル 640〜720行目コミット e289de5
- TypeScriptファイル 905〜920行目コミット e289de5
- TypeScriptファイル 1〜24行目コミット e289de5
- TypeScriptファイル 63〜84行目コミット e289de5
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。