Rebounder Tech Blog

Written by the people who actually run these systems in production.

A disabled Submit Button Dies Before Hydration

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

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

結論

Driving the disabled attribute from state leaves the button permanently disabled before hydration or when JS fails, making submission of the form itself impossible.

The short version

Decide a submit button’s disabled from React state (a controlled component) and the button is stuck disabled, doing nothing, before hydration or when the JS fails to load. disabled is a value released according to state only once JS runs, so it stays disabled through the moment JS has not caught up, and permanently if JS fails. The fix is “the button is always enabled, required stays, and the unmet-field decision is re-read at onSubmit from the DOM’s real values (FormData)”, leaving the server-side fail-closed validation unchanged.

What it looks like

On the web consent form where a customer agrees to an application’s contents and concludes the contract, the submit button was disabled until both the name field and the consent checkbox were filled.

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>

It looks like a helpful implementation: you cannot press it while fields are empty. But an independent review (by a separate Claude agent) pointed out that this “conclude the contract” button ends up in a state where pressing it does nothing before hydration, or if the JS fails to execute. On some browsers no native validation message appears either, and the server-side fail-closed redirect (?error=input) is rendered at the top of the page, out of sight from the form. From the user’s point of view, the name and the checkbox are both filled in and the button does not respond.

Why

Three problems stacked on this button.

  1. Fully controlled with value={name} / checked={consent}. The displayed values of the input and the checkbox are decided solely by React state. State initialisation only finishes after JS runs, so before hydration there is an intermediate state where “the input is visible but React is not actually managing its value”.
  2. disabled={!!missing}. The button’s enabled state was decided by missing, a value derived from state. missing is computed from state, so it too becomes correct only once JS runs. If JS fails, state stays at its initial values (name="", consent=false), so missing always says “empty” and the button is permanently disabled.
  3. required had been removed. Moving validation to the controlled component meant native required validation was no longer used. Without it, nothing remains to stop the form when JS is not working.

So for the single goal of “cannot be pressed until the fields are filled”, the enabling of the button itself had been made dependent on client-side JS. On the premise that JS always runs, this causes no problem — but for the instant before hydration, during a slow JS load, or after a script error halts execution, the button stays disabled and never recovers.

Fixing it

Three measures: do not disable the button, do not remove required, and make the decision from the DOM’s real values.

<form
  action={agreeApplication}
  onSubmit={(e) => {
    // Decide from the DOM's real values (FormData), not state. A password manager or
    // bfcache filling values without an input event must not kill the submission
    // through stale 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>

Four changes.

  • disabled removed from the button; it is always enabled. It is clickable even when JS is not running, and native form submission takes over.
  • required restored. The browser’s native validation stops an empty submission with no JS.
  • value/checked changed to defaultValue/defaultChecked, making them uncontrolled. Leaving the displayed value to the DOM itself removes the intermediate state before state initialisation.
  • onSubmit re-reads the real values from FormData rather than state, and preventDefaults if they are unmet. Unaffected by stale state, the decision uses only the DOM’s real values at the moment of submission. Safari shows no message when native required validation stops a submission, so onInvalid detects it and promotes the reason display to role="alert".

Server-side validation is unchanged. agreeApplication rejects with redirect(/p/${token}?error=input) if the name or the consent is missing. The client-side measures are a UX improvement only; the final rejection stays with the server’s fail-closed decision.

Preventing a repeat

The post-fix code carries a comment saying directly why disabled is not used.

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

The UX intent — “cannot be pressed until the fields are filled” — is itself right. The problem was making both the decision and the enforcement depend on the same JS. Separate the decision (showing what is missing) from the blocking (actually stopping the submission), and put the blocking on layers that work without JS — native HTML validation and server-side validation — which is where this kind of critical-operation form should land.

よくある質問

Q1Why is a controlled disabled attribute dangerous?

Deciding disabled from React state makes the button's enabled state depend on JS having run. Before hydration, or when the JS fails to load, state is still at its initial value, so the button is unintentionally stuck disabled and pressing it does nothing.

Q2Is it safe to keep required and leave the button always enabled?

It is. Native required validation stops an empty submission, and onSubmit re-reads the values from FormData as a second guard. Server-side fail-closed validation is also untouched, so a client-side defect alone cannot let an invalid submission through.

Q3Why judge from FormData rather than state?

Password managers, browser autofill and bfcache restoration can rewrite values without firing an input event. State cannot detect that and stays stale, so the decision at submit time has to re-read FormData — the DOM's real values.

確認した環境

  • Next.js 16.2.7 / React 19.2.4
  • Found in production review on 2026-07-23 and fixed the same day

この記事の根拠

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

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