Rebounder Tech Blog

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

『an invalid form control is not focusable』で送信が無言停止した

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

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

結論

hidden属性で隠したセクションの中にrequiredな入力欄が残っていると、そのフォームを送信しようとしたときブラウザは『どの欄が未入力か』を表示できず、無言で送信をブロックする。

結論

3ステップの申込フォームを1つの<form>にまとめ、各ステップをhidden属性(display:none相当)で出し分けていた。必須項目はステップ1に、送信ボタンはステップ2にある。ステップ1の「確認へ進む」ボタンはtype="button"で何も検証しないため、必須欄が空のままステップ2まで進めてしまえる。その状態でステップ2の送信ボタン(type="submit")を押すと、ブラウザのネイティブ検証は空の必須欄を見つけるが、そのステップは既にhiddenでフォーカスできず、エラーの出しようがなく黙って送信を中止する。 直し方はformnoValidateを付けてネイティブ検証を切り、代わりに「確認へ進む」の中で明示的にチェックしてその場にエラーを表示すること。

症状

学校広告の応援枠に申し込む /apply のフォームは、学校選び(ステップ0)→掲載内容・連絡先(ステップ1)→確認・送信(ステップ2)の3ステップで、useStatestep を切り替えながら単一の <form action={formAction}> の中に全ステップの入力欄を持っていた。ステップの出し分けは、それぞれを <section> で囲み hidden 属性を付けるだけで実装されている。

<form action={formAction}>
  {/* STEP 0: 学校を選ぶ+期間 */}
  <section hidden={step !== 0}>...</section>

  {/* STEP 1: 掲載する内容(お店・ご連絡先の必須欄はここ) */}
  <section hidden={step !== 1}>
    ...
    <input id="sf_name" name="name" required maxLength={200} ... />
    <input id="sf_email" name="email" type="email" required maxLength={200} ... />
    ...
    <button type="button" onClick={() => setStep(2)}>確認へ進む</button>
  </section>

  {/* STEP 2: 確認・申込(送信ボタンはここだけ) */}
  <section hidden={step !== 2}>
    ...
    <button type="submit" disabled={pending}>応援して申し込む</button>
  </section>
</form>

本人+Claudeによる本番実操作レビューで、担当者名やメールを入力しないままステップ2の確認画面まで進み、「応援して申し込む」を押すと画面に何の変化も起きないという詰みを見つけた。エラーメッセージも出ず、送信中の表示にもならず、ボタンを押した反応そのものが無い。ブラウザの開発者ツールのコンソールには次の警告だけが出ていた。

An invalid form control with name='name' is not focusable.

原因

ステップ間の移動はReactのstateを変えるだけのtype="button"で実装されている。

<button type="button" onClick={() => setStep(2)}>
  確認へ進む
</button>

type="button"のクリックはフォーム送信ではないため、ブラウザのネイティブ入力検証はここでは一切走らない。 必須の nameemail が空のままでも、ステップ2の確認画面へ普通に進めてしまう。

フォーム全体で唯一のtype="submit"ボタンは、ステップ2の中にある「応援して申し込む」だけだった。

<button type="submit" disabled={pending}>
  {pending ? "送信中…" : "応援して申し込む"}
</button>

利用者がこれを押した瞬間、ブラウザは<form>全体を対象にネイティブのHTML5検証を走らせる。nameemailrequiredなので空なら無効と判定されるが、この時点でstep2であり、これらの入力欄を含むステップ1のセクションはhidden={step !== 1}が真になって非表示(display:none)になっている。 display:noneの要素はフォーカスを受け取れない。ブラウザは検証エラーを利用者に提示するために該当欄へフォーカスしようとするが、それができないため、警告をコンソールへ吐くだけでフォーム送信を静かに中止する。可視のエラー表示も、スクロールも、何も起きない。

つまり「必須欄を後で埋めればいい」という設計に見えて、実際にはその必須欄へたどり着く手段そのものが、送信を試みる時点では画面から消えている構造になっていた。

直す

form要素にnoValidateを付け、ブラウザのネイティブ検証そのものを無効化した。

{/* noValidate: 入力ゲートは goConfirm(表示付き)とサーバー側検証が担う。
    ネイティブ検証は必須欄が hidden セクションにあると無言でブロックし詰まって見えるため切る。 */}
<form action={formAction} noValidate>

代わりに、ステップ1の「確認へ進む」ボタンに、明示的な入力チェックを行うgoConfirmを割り当てた。

const goConfirm = () => {
  const missing: string[] = [];
  if (creativeMode === "omakase") {
    if (!omBiz.trim()) missing.push("業種・お店の種類");
    if (!omMessage.trim()) missing.push("いちばん伝えたいこと");
  }
  if (!company.trim()) missing.push("お店・会社の名前");
  if (!contactName.trim()) missing.push("ご担当者名");
  if (!email.trim()) missing.push("メールアドレス");
  else if (!EMAIL_RE.test(email.trim()))
    missing.push("メールアドレス(形式をご確認ください)");
  if (missing.length > 0) {
    setStepError(`未入力の項目があります:${missing.join("・")}`);
    return;
  }
  setStepError(null);
  setStep(2);
};

未入力があればrole="alert"のメッセージをその場(まだステップ1が見えている状態)に表示し、ステップ2へは進ませない。

{stepError && (
  <p role="alert" className="mt-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
    {stepError}
  </p>
)}

このチェックはHTML5のrequired属性が見ていなかったomakase選択時の業種・伝えたいことも対象にできる。ブラウザのネイティブ検証はrequiredが付いた欄しか見ないが、goConfirmは業務ルール(おまかせ選択時はこの2項目も必須)をそのままコードで表現できる。最終的な検証はサーバー側のServer Actionに委ねる構成は変えていない。

再発防止

「複数ステップの入力を1つの<form>にまとめ、hiddenで出し分ける」構成そのものが、ネイティブのHTML5検証と相性が悪い。 ブラウザの入力検証は「フォーム全体」を見て「フォーカスできる最初の無効な欄」を探す仕組みであり、どのステップが今表示されているかは考慮しない。必須欄と送信ボタンが別ステップに分かれている時点で、検証対象の欄が送信操作の瞬間に画面から消えている状態が起こり得る。

このパターンで安全側に倒すなら、①ステップをまたぐフォームはnoValidateを前提にしてステップ移動のたびに明示的なチェックを書く、②あるいはステップごとに別々の<form>に分けてネイティブ検証をステップ内で完結させる、のどちらかを最初から選んでおく必要がある。「動くように見えて実は送信できない」状態は自動テストで気づきにくく、この件も本番の実操作レビューで初めて表面化した。

よくある質問

Q1なぜ送信ボタンを押しても何も起きなかったのですか?

3ステップの入力を1つのform要素にまとめ、各ステップはhidden属性(display:none相当)で出し分けていました。requiredな入力欄はステップ1にあり、送信ボタンはステップ2にあります。ステップ2まで進んだ時点でステップ1のセクションはhiddenになっており、送信を試みたブラウザのネイティブ検証は空のrequired欄を見つけても、非表示のためフォーカスできず、エラー表示のしようがなく黙って送信を中止していました。

Q2『確認へ進む』ボタンを押した時点で検証されなかったのはなぜですか?

このボタンはtype="button"で実装されており、クリックしてもReactのstateを変えてステップを進めるだけでした。ブラウザの入力検証はtype="submit"のボタンが押されたとき、またはフォームが送信されようとしたときにしか走らないため、ステップ移動そのものでは何もチェックされていませんでした。

Q3ブラウザの開発者ツールには何か出ていましたか?

コンソールに An invalid form control with name='name' is not focusable. という警告が出ていました。ブラウザ側は『検証エラーがあるが、その欄にフォーカスできないので利用者に見せられない』と判断しており、画面上には一切変化が起きません。

Q4直し方はどう変わりましたか?

form要素にnoValidateを付けてネイティブ検証そのものを無効化し、代わりに『確認へ進む』ボタンの中で明示的な入力チェックを行い、未入力があればrole="alert"のエラーメッセージをその場に表示するようにしました。最終的な検証はサーバー側のServer Actionに委ねます。

確認した環境

  • Next.js 16.2.7 / React 19.2.4
  • 2026-07-18、本人+Claudeによる本番実操作レビューで発見・同日中に修正

この記事の根拠

  • TypeScriptファイル 65〜100行目コミット 21b33a5
  • TypeScriptファイル 296〜345行目コミット 21b33a5
  • TypeScriptファイル 394〜403行目コミット 21b33a5

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