Rebounder Tech Blog

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

フォームの一部だけ制御値にすると、保存失敗後の再送信で設定が黙って元に戻る

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

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

結論

フォームの入力欄の一部だけを制御値にすると、検証エラーで戻された画面は制御している項目だけが残り、制御していない項目だけが黙って defaultValue へ戻る。フォームが無事に見えるぶん、気づかず再送信しやすい。

結論

フォームの入力欄の一部だけを制御値にすると、検証エラーで戻された画面は制御している項目だけが残り、制御していない項目だけが黙って defaultValue へ戻る。フォームが無事に見えるぶん、気づかず再送信しやすい。

広告枠の作成・編集フォームで、定員・価格・期間だけを useState で制御し、枠名・公開設定などは defaultValue のままにしていたところ、検証エラーで戻った画面は「定員と価格は残っているのに、枠名と公開設定だけ既定値に戻っている」状態になった。

症状

このフォームは作成・編集を共用しており、React 19 の <form action={fn}> で送信する。定員・価格・期間の3系統だけは前のコミットで plan という state に載せ、valueonChange で制御していた。枠名・説明・スコープ対象・開始月・公開設定・動画可は、初期表示用の defaultValue を渡すだけの非制御(uncontrolled)入力のままだった。

モニタ直指定の枠を保存したときの v2 照会失敗など、検証エラーで updateLoop / addLoop から戻ってくると、定員・価格・期間は入力した値のまま画面に残る。ところが枠名・説明・スコープ対象・開始月・公開設定・動画可は、送信前に何を入力していても defaultValue の値へ戻っていた。全項目が空に戻る設計より始末が悪いのは、一部の項目(定員・価格)が生きて見えるためフォーム自体は正常に見え、赤いエラーメッセージだけを見てもう一度保存を押しやすいことだった。非公開にしたつもりの枠を、公開設定が元に戻ったまま再送信して公開のまま保存してしまう、という事故になり得る。

原因

React 19 は <form action={fn}> の送信時、成功・失敗に関わらず制御していない入力をリセットする。定員・価格・期間だけを value + onChange で制御し、残りを defaultValue のままにしていたのは、直前のコミットで「再マウントで定員・価格が消える」問題にだけ対処し、他の項目には手を付けていなかったためだった。

// 直す前:定員・価格・期間だけを 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,
});

枠名の <input>value={draft.label} ではなく defaultValue={loop?.label ?? ""} を渡すだけで、onChange を持たなかった。React からは「制御していない入力」として扱われ、送信のたびにリセット対象になる。

直す

枠名・説明・スコープ対象・開始月・公開設定・動画可も含めて、フォームの入力欄すべてを1つの draft state に統合した(state 名も plan から draft に変更。料金プラン以外の項目も持つようになったため)。

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 ?? "",
  // ...料金プラン系はそのまま
});

料金プランのプリセットボタン(applyPreset)は、以前は初期値(EMPTY_PLAN)を起点にオブジェクト全体を上書きしていた。この書き方のまま EMPTY_DRAFT 起点に切り替えると、プリセットを押した瞬間に先に入れた枠名や公開設定まで消えてしまう。そのため、現在の draft を土台に、料金プランの8項目だけを上書きするよう変更した。

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 }),
  }));

再発防止

この不具合を防ぐテストは元々あったが、判定が「defaultValue が無いこと」という否定形だった。この判定だと、valueonChange も持たない入力欄(値を一度も書き換えていない欄)を見逃してしまう。制御値になっていることを肯定形で要求するよう書き換え、value(または checked)と onChange の両方を持つことを検査するようにした。あわせて、hidden 入力は現在値を持ち回るだけの用途なので検査対象から外している。

さらに、このテストは LoopFormLoopCard という2つの関数定義の間の文字列を切り出して検査する実装になっている。関数の並び順が入れ替わると切り出し範囲が空文字列になり、検査全体が何も見ずに成功してしまう。そのため LoopCard の定義が LoopForm より後ろにあることを、テストの前提としてあわせて確認するようにした。

制御していない入力欄自体は、間違いなくバグではない。ただし <form action> を使う限り、フォームの一部だけを制御値にする設計は「検証エラー後の再送信で、制御していない項目だけが黙って戻る」という非対称な挙動を生む。フォーム内の入力は全部を同じ扱いにするか、全部を defaultValue のままにするかのどちらかに揃えたほうが、この種の見た目上は正常に見える壊れ方を避けやすい。

同じ広告枠の管理画面で、UI 側の制限をサーバー側が見ていなかった話は UIでは編集を凍結していても、サーバー側が同じ条件を見ていなければ偽装POSTで書き換えられる に書いている。

よくある質問

Q1なぜ一部の項目だけ制御値にしていたのですか?

前のコミットで、再マウント時に入力が消えるのを防ぐ目的で、定員・価格・期間だけを useState の state に載せて value と onChange で制御していました。枠名・説明・スコープ対象・開始月・公開設定・動画可は defaultValue のままで、意図的な取捨選択ではなく段階的に増やしていた途中の状態でした。

Q2React 19 の <form action> はどんな時に入力をリセットしますか?

送信すると、成功・失敗に関わらずフォーム内の制御していない(uncontrolled)入力を defaultValue へリセットします。value と onChange で制御している入力はこのリセットの対象外です。

Q3全項目を制御値にする以外に直しようはなかったのですか?

枠名・説明・スコープ対象・開始月・公開設定・動画可も含めて1つの draft state に統合し、value と onChange で制御するようにしました。state 名も plan から draft に変えています(料金プラン以外の項目も持つようになったため)。

Q4プリセットボタンを直したとき、なぜ EMPTY_DRAFT を起点にしなかったのですか?

プリセットは元々、初期値(EMPTY_PLAN)を起点にオブジェクト全体を上書きしていました。これを EMPTY_DRAFT 起点のままにすると、先に入れた枠名や公開設定までプリセット押下で消えてしまうため、現在の draft を土台に料金プランの8項目だけを上書きするよう変更しました。

Q5この不具合が起きないことをテストで検出できますか?

できます。ただし「defaultValue が無いこと」という否定形の判定では、value も onChange も無い欄(何も書き換えていない欄)を見逃します。value または checked と onChange の両方を持つことを肯定形で要求するテストに書き換えて、この見逃しを塞ぎました。

確認した環境

  • Next.js 16.2.7 / React 19.2.4
  • 2026-07-24 のコミット時点

この記事の根拠

  • TypeScriptファイル 598〜710行目コミット 2c0556a
  • TypeScriptファイル 56〜94行目コミット 2c0556a

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