Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

フォームをkeyで再マウントすると、検証エラー直後の保存で入力値が既定値に戻る

公開 読了時間 約8分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

フォームを<form key={formGen}>で送信のたびに再マウントする実装では、defaultValueで渡した未制御の入力欄は検証エラーで戻された直後の保存でも一緒にリセットされ、画面に見えている値ではなく既定値のまま保存される。

結論

<form key={formGen}> のように送信のたびにフォームを再マウントする実装では、defaultValue だけを渡している未制御の入力は、検証エラーで差し戻された直後の保存でも一緒にリセットされる。画面には入力し直した値が見えていても、保存されるのは再マウント後の既定値になる。

症状

広告枠の作成・編集フォームで、定員を5にするつもりで枠を作成したところ、実際に保存されたのは定員1だった。監査ログには「create 定員1 → 1分後 update 定員5」という記録が残っており、作成直後に編集で手動で直した形になっている。占有が入る前だったので実害は無かったが、公開中の枠だったため、直っていない間は「残り1枠」として表示されていた。

このフォームは作成・編集を共用しており、React 19 の <form action={fn}> で送信する。定員の入力欄は type="number"min={1}・上限なし、DB 側の制約も capacity >= 1 しか無い。定員が意図せず小さい値になり得る作りが2つ重なっており、片方はフォーカス中のホイール操作で数値が min まで転がる既知の問題、もう片方がここで扱う フォームの再マウントによる未制御入力のリセット だった。

原因

このフォームは送信のたびにキーを変えて丸ごと作り直す設計になっていた。

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)r?.ok の外にあり、送信の成否に関わらず必ず実行される。 key が変わるたびに React はこの <form> の DOM を古いものごと破棄し、新しいツリーとしてマウントし直す。これは React 19 の <form action={fn}> が持つ「送信すると制御していない入力を defaultValue へリセットする」挙動に対処するための設計で、学校・スコープ・モニタなど state で制御している選択は、この自動リセットの対象にならないぶん key での再マウントで DOM を作り直して合わせていた。

問題は、定員などの入力欄が value ではなく defaultValue だけで初期値を渡していたことだった。

<input
  name="capacity"
  type="number"
  min={1}
  defaultValue={loop?.capacity ?? 10}
  className={input}
/>

defaultValue を渡すだけの入力欄は React から見て「未制御(uncontrolled)」であり、DOM 上の現在値は React の管理外にある。再マウントは DOM を新しく作り直す操作なので、未制御入力は毎回 defaultValue の値から始まり直す。 検証エラーで addLoop / updateLoop から失敗が返っても setFormGen は同じく実行されるため、フォームは再マウントされ、直前に画面へ入力していた定員の値は消えて defaultValue の値に戻る。エラーメッセージが出るだけで入力欄自体は空にならないため、値が既定に戻っていること自体に気づきにくい。

プリセットボタンも同じ経路で影響を受けていた。

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");
    ...

プリセットは formRef.current.elements から DOM 要素を直接取得し、el.value = v で書き換えていた。これは React の state を経由しない DOM への直接操作であり、次に再マウントが起きれば同じく defaultValue へ戻る。プリセットを押した直後にそのまま送信すれば問題は表面化しないが、検証エラーで一度差し戻されたあとに保存すると、プリセットで入れたはずの値も既定値に潰れる。

直す

定員・秒数・価格・期間などプリセットが触る8項目を、defaultValue の未制御入力から 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}
/>

制御値は key の変化による再マウントの影響を受けない。React が管理しているのは plan という state であり、DOM が作り直されても value={plan.capacity} は同じ state を参照し続けるため、検証エラーで再マウントが起きても入力し直した値は保たれる。

プリセットも DOM 直書きをやめ、state を更新するだけの形に変えた。

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 はどこからも参照されなくなり、削除できた。

再発防止

<input> タグを1つずつ取り出し、プリセットが触る欄(capacity / slot_seconds / term_fee / monthly_fee_per_monitor / term_months / max_term_months / card_only)が defaultValuedefaultChecked を持っていないこと、そして applyPresetformRef.current.elements ではなく setPlan を呼んでいることを検査する静的テストを追加した。

it("プリセットが設定する欄は制御値で持つ(再マウントで入力が消えない)", () => {
  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.join(", ")}`).toEqual([]);
});

<input> タグの切り出しは <input から次の /> までを1タグとして取る実装にしている。属性にアロー関数(onChange={(e) => ...})を持つ入力欄は途中に > を含むため、正規表現1つで囲おうとすると onChange を持つ欄そのものを見落とす形になり、検査したい相手が素通りしてしまう。

このときは定員・価格など8項目だけを制御値にしたが、枠名や公開設定など残りの入力欄はまだ defaultValue のままだった。同じフォームで別の欄が同じ現象を起こした話は フォームの一部だけ制御値にすると、保存失敗後の再送信で設定が黙って元に戻る に書いている。

よくある質問

Q1なぜフォームを送信のたびにkeyで再マウントしていたのですか?

React 19 の <form action={fn}> は送信すると成否に関わらず制御していない入力を defaultValue へ戻す一方、学校・スコープ・モニタなど state で制御している選択はこのリセットの対象外になる。DOM と state が食い違ったまま残らないよう、送信のたびに key を変えてフォームごと作り直す設計にしていた。

Q2keyで再マウントするだけで何が問題なのですか?

再マウント自体は state で制御している入力には影響しない。問題は defaultValue だけを渡している未制御の入力で、再マウントのたびに defaultValue の値へ戻る。検証エラーで差し戻された直後に保存すると、直前に入力し直した値ではなく既定値のまま保存されてしまう。

Q3プリセットボタンで値を入れていた場合はどうなりますか?

この実装のプリセットボタンは formRef.current.elements への直接代入(el.value = ...)でDOMを書き換えていた。React の state を経由しない変更のため、次の再マウントで同じく defaultValue へ戻り、押した直後に送信しなければプリセットで入れた値ごと消える。

Q4直すのに defaultValue を value に変えるだけで十分ですか?

不十分。value と onChange の組で制御値にし、プリセットも DOM 直書きではなく state の更新に変える必要がある。どちらか一方だけ直すと、もう一方の経路で同じ現象が再現する。

Q5この問題をテストで検出できますか?

できる。<input> タグを1つずつ取り出し、プリセットが触る欄が defaultValue や defaultChecked を持っていないこと、および applyPreset が DOM の elements.namedItem ではなく state の setPlan を呼んでいることを検査する静的テストを追加した。

確認した環境

  • Next.js 16.2.7 / React 19.2.4
  • 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

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。