Rebounder Tech Blog

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

送信ボタンをFormDataで判定しないとハイドレーション前は押せない

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

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

結論

サーバー側の状態からdisabled属性を制御する実装は、ハイドレーション前やJS失敗時にボタンが永久に無効のままになり、フォーム送信そのものを不能にする。

結論

送信ボタンのdisabledをReactのstate(制御コンポーネント)から決めると、ハイドレーション前やJSの読み込み失敗時にボタンが無効固定のまま動かなくなる。 disabledはJSが動いて初めてstateに応じて解除される値なので、JSが間に合っていない一瞬や、JSが失敗した場合はずっと無効のままになる。直し方は「ボタンは常時有効・requiredは残す・未充足の判定はonSubmitでDOMの実値(FormData)から取り直す」で、サーバー側のfail-closedな検証は変更しない。

症状

顧客が申込書の内容に同意して契約を締結するWeb同意フォームで、氏名の入力とチェックボックスの同意が揃うまで送信ボタンをdisabledにする実装になっていた。

const [name, setName] = useState("");
const [consent, setConsent] = useState(false);

const missing = !name.trim()
  ? "ご担当者氏名をご入力ください。"
  : !consent
    ? "下のチェックボックス(同意のうえ、申し込みます)にチェックしてください。"
    : null;

<input
  id="agreed_name"
  name="agreed_name"
  value={name}
  onChange={(e) => setName(e.target.value)}
/>
<input
  type="checkbox"
  name="consent"
  checked={consent}
  onChange={(e) => setConsent(e.target.checked)}
/>
<button type="submit" disabled={!!missing}>
  同意して申し込みを確定する
</button>

見た目は「未入力なら押せない」という親切な実装に見える。だが独立したレビュー(別のClaude agentによるレビュー)で、この「契約締結」ボタンがハイドレーション前やJSの実行に失敗した場合に押しても何も起きない状態になることを指摘された。ブラウザによってはネイティブの検証メッセージも出ず、サーバー側のfail-closedなリダイレクト(?error=input)もページ最上部に出るためフォームの位置からは見えない。ユーザーから見ると、氏名もチェックも済ませているのにボタンが反応しない状態になりうる。

原因

このボタンには3つの問題が重なっていた。

  1. value={name} / checked={consent} の完全な制御コンポーネント化。 入力欄とチェックボックスの表示値がReactのstateだけで決まる。stateの初期化はJSの実行後にしか終わらないため、ハイドレーション前は「見た目は入力欄があるのに、実際にはReactが値を管理していない」中間状態がある。
  2. disabled={!!missing} ボタンの有効/無効をmissingというstateの導出値で決めていた。missingはstateから計算されるので、これもJSが動いて初めて正しい値になる。JSが失敗すればstateは初期値(name="", consent=false)のままなので、missingは常に「未入力」を指し、ボタンは永久に無効になる。
  3. requiredを外していた。 制御コンポーネント側でバリデーションを持たせたことで、ネイティブのrequired検証を使わなくなっていた。ネイティブ検証が無いと、JSが機能していない状況でフォーム自体を止める手段が何も残らない。

つまり「入力が揃うまで押せないようにする」という同じ目的のために、クライアント側のJSにボタンの有効化そのものを依存させてしまっていた。JSが必ず動く前提であれば問題は起きないが、ハイドレーション前の一瞬・低速回線でのJS読み込み中・スクリプトエラーによる実行停止のいずれでも、ボタンは無効固定のまま復帰しない。

直し方

対策は「ボタンは無効化しない・requiredは外さない・判定はDOMの実値で行う」の3点。

<form
  action={agreeApplication}
  onSubmit={(e) => {
    // 判定は state ではなく DOM の実値(FormData)で行う。パスワードマネージャや
    // bfcache が input イベント無しで値を入れても、state の取り残しで送信を殺さない。
    const fd = new FormData(e.currentTarget);
    const n = String(fd.get("agreed_name") ?? "").trim();
    const c = fd.get("consent") === "on";
    setName(n);
    setConsent(c);
    if (!n || !c) {
      e.preventDefault();
      setBlocked(true);
    }
  }}
>
  <input
    id="agreed_name"
    name="agreed_name"
    required
    defaultValue={name}
    onChange={(e) => setName(e.target.value)}
    onInvalid={() => setBlocked(true)}
  />
  <input
    type="checkbox"
    name="consent"
    required
    defaultChecked={consent}
    onChange={(e) => setConsent(e.target.checked)}
    onInvalid={() => setBlocked(true)}
  />
  <button type="submit">
    同意して申し込みを確定する
  </button>
</form>

変更点は4つ。

  • ボタンからdisabledを外し、常時有効にした。 JSが動いていなくてもクリックでき、ネイティブのフォーム送信に任せられる。
  • requiredを戻した。 JSが無くてもブラウザのネイティブ検証が未入力を止める。
  • value/checkeddefaultValue/defaultCheckedに変え、非制御コンポーネントにした。 表示値をDOMそのものに任せることで、state初期化前の中間状態を無くした。
  • onSubmitでstateではなくFormDataから実値を読み直し、未充足ならpreventDefaultする。 stateの取り残しに影響されず、送信直前のDOMの実値だけで判定する。SafariはネイティブのRequired検証で止めた場合にメッセージを出さないため、onInvalidイベントで検知して理由表示をrole="alert"へ格上げしている。

サーバー側の検証は変更していない。agreeApplicationは氏名または同意が欠けていればredirect(/p/${token}?error=input)で弾く。クライアント側の対策はあくまでUXの改善であり、最終的な拒否はサーバー側のfail-closedな判定に委ねたままにした。

再発防止

修正後のコードには、なぜdisabledを使わないかを直接コメントで残した。

// ⚠ ボタンは無効化しない・`required` も外さない(Reviewer P2-1)。契約締結という最重要操作を
// JS の実行に依存させると、ハイドレーション前/失敗時やパスワードマネージャの値復元で詰む。
// サーバー側(agreeApplication の !name || !consent → ?error=input)が最終的な fail-closed。

「入力が揃うまで押せないようにする」というUXの意図自体は正しい。問題は、その判定と実行をどちらも同じJSに依存させたことだった。判定(何が足りないかを表示する)とブロック(実際に送信を止める)を分け、ブロックの側はネイティブのHTML検証とサーバー側検証という「JSが無くても効く層」に持たせるのが、この種の最重要操作フォームでの落としどころになる。

よくある質問

Q1disabled属性を制御コンポーネントにするとなぜ危険なのですか?

disabledの真偽をReactのstateから決めると、ボタンの有効/無効はJSの実行結果に依存します。ハイドレーション前やJSの読み込み失敗時はstateが初期値のままなので、意図せず無効固定になり、押しても何も起きなくなります。

Q2required属性を残したままボタンを常時有効にして大丈夫ですか?

大丈夫です。ネイティブのrequired検証が未入力を止め、さらにonSubmitでFormDataから値を読み直して二重にガードできます。サーバー側のfail-closedな検証も外していないので、クライアントの不具合だけで不正な送信が通ることはありません。

Q3なぜstateではなくFormDataで判定する必要があるのですか?

パスワードマネージャやブラウザのオートフィル、bfcacheからの復元はinputイベントを発生させずに値だけを書き換えることがあります。stateはこの変化を検知できず古い値のままになるため、送信時にDOMの実値であるFormDataを読み直して判定する必要があります。

確認した環境

  • Next.js 16.2.7 / React 19.2.4
  • 2026-07-23 に本番のレビューで発覚・同日修正

この記事の根拠

  • TypeScriptファイル 46〜79行目コミット 9cb296c
  • TypeScriptファイル 37〜106行目コミット 5e1c4d6
  • TypeScriptファイル 27〜32行目コミット 5e1c4d6

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