Half-Controlled Forms Silently Revert on Resubmit
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
Control only some of a form's inputs and a screen returned by a validation error keeps the controlled fields while the rest revert silently to defaultValue. The form looks fine and gets resubmitted.
The short version
Control only some of a form’s inputs, and a screen returned by a validation error keeps the controlled fields while the uncontrolled ones revert silently to defaultValue. The form looks fine, so it gets resubmitted.
In the ad-slot create/edit form, capacity, price and term were controlled with useState while the slot name, published setting and others stayed on defaultValue. A screen returned by a validation error ended up in a state where “capacity and price are still there, but the slot name and published setting are back at their defaults.”
What it looks like
This form is shared between create and edit and submits through React 19’s <form action={fn}>. An earlier commit had put the three groups — capacity, price and term — into a plan state, controlled with value and onChange. The slot name, description, scope, start month, published setting and video-allowed remained uncontrolled inputs, given only a defaultValue for the initial render.
When a validation error returns from updateLoop / addLoop — a failed v2 lookup while saving a slot pinned to a specific monitor, say — capacity, price and term stay on screen as entered. But the slot name, description, scope, start month, published setting and video-allowed had reverted to their defaultValue, whatever had been entered before submitting. This is worse than a design where everything resets, because some fields (capacity, price) look alive, so the form itself looks normal and it is easy to glance at the red error message and press save again. A slot someone meant to unpublish can be resubmitted with the published setting back at its default, and saved published.
Why
React 19, on <form action={fn}> submit, resets uncontrolled inputs whether it succeeded or failed. Only capacity, price and term were controlled with value + onChange, with the rest left on defaultValue, because the immediately preceding commit had addressed only the “capacity and price vanish on remount” problem and had not touched the other fields.
// before the fix: only capacity, price and term held in state
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,
});
The slot-name <input> was given defaultValue={loop?.label ?? ""} rather than value={draft.label}, and had no onChange. React treats it as an uncontrolled input, which makes it a reset target on every submit.
Fixing it
We merged every input in the form — slot name, description, scope, start month, published setting and video-allowed included — into a single draft state (renaming the state from plan to draft, since it now holds fields beyond the pricing plan).
const EMPTY_DRAFT = {
label: "",
description: "",
scopeRef: "",
startMonth: "",
planKind: "",
capacity: "10",
slotSeconds: "30",
termFee: "",
monthlyFee: "",
termMonths: "12",
maxTermMonths: "",
cardOnly: false,
published: false,
allowVideo: false,
};
const [draft, setDraft] = useState({
label: loop?.label ?? "",
description: loop?.description ?? "",
scopeRef: loop?.scopeRef ?? "",
startMonth: loop?.startMonth ?? "",
// ...pricing-plan fields unchanged
});
The pricing-plan preset button (applyPreset) used to overwrite the whole object starting from the initial value (EMPTY_PLAN). Switching that shape to start from EMPTY_DRAFT would wipe the slot name and published setting already entered the moment the preset was pressed. So we changed it to overwrite only the eight pricing-plan fields on top of the current draft.
const applyPreset = (kind: "gakka" | "shinro") =>
setDraft((d) => ({
...d,
planKind: kind,
slotSeconds: "30",
termMonths: "12",
maxTermMonths: "",
monthlyFee: "",
...(kind === "gakka"
? { capacity: "5", termFee: "200000", cardOnly: false }
: { capacity: "30", termFee: "10000", cardOnly: true }),
}));
Preventing a repeat
A test guarding against this bug already existed, but its assertion was the negative “there is no defaultValue”. With that assertion, an input carrying neither value nor onChange — a field nobody ever rewrote — slips through. We rewrote it to require being controlled positively, checking that each field has both value (or checked) and onChange. hidden inputs, which exist only to carry a current value along, were excluded from the check.
That test is also implemented by slicing the string between the two function definitions LoopForm and LoopCard and inspecting it. Reorder the functions and the slice becomes an empty string, so the whole check passes having looked at nothing. We therefore also assert, as a precondition of the test, that LoopCard is defined after LoopForm.
An uncontrolled input is certainly not a bug in itself. But as long as <form action> is used, controlling only part of a form produces the asymmetric behaviour “on resubmit after a validation error, only the uncontrolled fields silently revert”. Treating every input in a form the same way — all controlled, or all left on defaultValue — makes this kind of break, the kind that looks normal, easier to avoid.
On the same ad-slot admin screen, the story of a UI restriction the server never checked is in A UI freeze not enforced server-side is rewritable by a forged POST.
よくある質問
Q1Why were only some fields controlled?
An earlier commit put capacity, price and term into useState, controlled with value and onChange, to stop inputs vanishing on remount. Label, description, scope, start month, published and video-allowed stayed on defaultValue — not a deliberate split, just a partway state.
Q2When does React 19's <form action> reset inputs?
On submit it resets the form's uncontrolled inputs to defaultValue, whether the submission succeeded or failed. Inputs controlled with value and onChange are exempt from that reset.
Q3Was there a fix other than controlling every field?
We merged label, description, scope, start month, published and video-allowed into a single draft state, controlled with value and onChange. The state was also renamed from plan to draft, since it now holds fields beyond the pricing plan.
Q4Why didn't the fixed preset button start from EMPTY_DRAFT?
The preset originally overwrote the whole object starting from the initial value (EMPTY_PLAN). Keeping that with EMPTY_DRAFT would wipe the label and published setting already entered, so we changed it to overwrite only the eight pricing-plan fields on top of the current draft.
Q5Can a test detect that this bug is absent?
Yes. But a negative assertion — "there is no defaultValue" — misses inputs with neither value nor onChange (fields nobody rewrote). We rewrote it as a positive requirement that each field carries both value (or checked) and onChange, closing that blind spot.
確認した環境
- Next.js 16.2.7 / React 19.2.4
- As of the commit on 2026-07-24
この記事の根拠
- TypeScriptファイル 598〜710行目コミット 2c0556a
- TypeScriptファイル 56〜94行目コミット 2c0556a
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。