送信ボタン — 二重送信防止(ローディング付き)

フォーム入力 初級

このコンポーネントについて

業務アプリのフォームで頻発する不具合の代表格が「送信ボタンの二重送信」です。通信に時間がかかる間にユーザーがボタンを連打したり、反応がないように見えて再クリックしてしまい、同じ内容が2件・3件と登録されてしまうケースは実務でよく起こります。このページでは、送信ボタンをクリックした瞬間に無効化してローディング状態へ切り替え、処理中の連打を確実にブロックする実装パターンを提供します。

さらに「送信回数カウンター」を画面に表示し、どれだけ連打しても実際の送信処理が1回しか実行されないことをその場で確認できるようにしています。通信成功時だけでなく、失敗時にボタンを再度有効化して再送信できるようにする、実務で見落としがちな分岐も含めています。

  • クリック即時の無効化+ローディング表示 — ボタン押下と同時に disabled 化し、テキストを「送信中...」に変更してスピナーを表示。連打による多重送信を未然に防ぐ
  • 送信回数カウンターで二重送信防止を可視化 — 連打・ダブルクリックしても実際の送信処理は1回しか実行されないことを、カウンターの数値で目視確認できる
  • 成功 / 失敗の2パターン — デモ切替ラジオで成功・失敗を選択可能。成功時はボタンがチェックマーク表示のまま維持され、失敗時はエラーメッセージを表示しつつボタンを再度有効化して再送信できる状態に戻す
  • Enterキー送信にも対応したガード — クリックイベントではなくフォームの submit イベントでガードするため、テキスト入力中のEnterキー送信でも同様に二重送信を防止する
  • 最小構成のフォーム — 入力項目は名前・メールアドレスの2つのみとし、送信ボタンの状態管理という本題に焦点を絞る

実装のポイント・注意点

二重送信防止のガードは、ボタンの click イベントではなくフォームの submit イベントに実装するのがポイントです。テキスト入力欄でEnterキーを押して送信する操作も submit イベントとして発火するため、ボタンのクリックだけをガードすると「入力欄でEnter連打」による二重送信を取りこぼしてしまいます。

ガードの実装は、フラグ変数(isSubmitting)とボタンの disabled 属性の両方を使うのが安全です。ハンドラの先頭で if (isSubmitting) return; のように早期リターンし、通過したら即座に isSubmitting = true とボタンの disabled = true を同期的にセットします。この「即座に・同期的に」という点が重要で、非同期処理が始まる前にガードを立てないと、処理開始までのわずかな間に連打が通ってしまいます。

実際の通信処理(fetch 等)に差し替える際は、成功・失敗いずれで終わっても最終的に isSubmitting を適切な状態に戻す処理を書き忘れないよう注意してください。これを忘れると、通信が失敗した場合にボタンが永久に無効化されたままになってしまいます。

画面に表示している「送信回数カウンター」はデモ専用の可視化要素です。二重送信防止が効いていることを検証しやすくするための表示であり、実際の実装に組み込む必要はありません。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

送信結果プレビュー:

送信回数カウンター: 0

送信ボタンを連打(ダブルクリック等)しても、実際の送信は1回しか実行されません。

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>送信ボタン 二重送信防止 サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="submit-lock-card">
  <form class="submit-lock-form" id="submitLockForm" novalidate>
    <div class="submit-lock-field">
      <label class="submit-lock-label" for="submitLockName">名前</label>
      <input class="submit-lock-input" type="text" id="submitLockName" name="name" placeholder="山田 太郎">
    </div>

    <div class="submit-lock-field">
      <label class="submit-lock-label" for="submitLockEmail">メールアドレス</label>
      <input class="submit-lock-input" type="email" id="submitLockEmail" name="email" placeholder="[email protected]">
    </div>

    <!-- 送信失敗時に表示するエラーメッセージ -->
    <p class="submit-lock-error" id="submitLockError" hidden>
      送信に失敗しました。もう一度お試しください。
    </p>

    <button class="submit-lock-btn" type="submit" id="submitLockBtn">
      <span class="submit-lock-btn-text">送信する</span>
      <svg class="submit-lock-btn-spinner" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" hidden aria-hidden="true">
        <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"/>
      </svg>
      <svg class="submit-lock-btn-check" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" hidden aria-hidden="true">
        <polyline points="20 6 9 17 4 12"/>
      </svg>
    </button>
  </form>

  <!-- デモ用の可視化要素。実際の実装では不要 -->
  <p class="submit-lock-counter">送信回数カウンター: <span id="submitLockCount">0</span>回</p>
  <p class="submit-lock-hint">送信ボタンを連打(ダブルクリック等)しても、実際の送信は1回しか実行されません。</p>
</div>

<script src="./script.js"></script>
</body>
</html>
:root {
  --color-primary: #2B7FE8;
  --color-primary-dark: #1E6ECF;
  --color-error: #E53E3E;
  --color-border: #D0D7E0;
  --color-text: #1A2B3C;
  --color-muted: #5A6A7A;
}

* { box-sizing: border-box; }

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: #F4F6F9;
  display: flex;
  justify-content: center;
  align-items: center;
  min-height: 100vh;
  margin: 0;
  padding: 16px;
}

/* ===カード=== */
.submit-lock-card {
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
  padding: 32px;
  width: 100%;
  max-width: 400px;
}

/* ===フィールド=== */
.submit-lock-field { margin-bottom: 16px; }

.submit-lock-label {
  display: block;
  font-size: 14px;
  font-weight: 600;
  color: var(--color-text);
  margin-bottom: 6px;
}

.submit-lock-input {
  width: 100%;
  padding: 10px 12px;
  font-size: 15px;
  border: 1.5px solid var(--color-border);
  border-radius: 8px;
  outline: none;
  transition: border-color 0.15s;
  font-family: inherit;
}

.submit-lock-input:focus { border-color: var(--color-primary); }

/* ===エラーメッセージ=== */
.submit-lock-error {
  color: var(--color-error);
  font-size: 13px;
  margin: -8px 0 16px;
}
/* hidden属性の打ち消し(このセレクタはdisplay:blockなので本来は不要だが明示のため記述) */
.submit-lock-error[hidden] { display: none; }

/* ===送信ボタン=== */
.submit-lock-btn {
  width: 100%;
  padding: 12px;
  background: var(--color-primary);
  color: #fff;
  border: none;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: background 0.15s;
  font-family: inherit;
}

.submit-lock-btn:hover:not(:disabled) { background: var(--color-primary-dark); }
.submit-lock-btn:disabled { opacity: 0.75; cursor: not-allowed; }

/* スピナーアニメーション */
@keyframes spin { to { transform: rotate(360deg); } }
.submit-lock-btn-spinner { animation: spin 0.8s linear infinite; }

/* SVGのhidden属性はブラウザによっては効かないことがある(初期非表示の保険) */
.submit-lock-btn-spinner[hidden],
.submit-lock-btn-check[hidden] { display: none; }

/* ===デモ専用の補足表示=== */
.submit-lock-counter {
  margin: 16px 0 4px;
  font-size: 13px;
  color: var(--color-muted);
  text-align: center;
}

.submit-lock-hint {
  margin: 0;
  font-size: 12px;
  color: var(--color-muted);
  text-align: center;
}

@media (max-width: 480px) {
  .submit-lock-card { padding: 24px; }
}
// ===== 動作確認の切り替え =====
// true → 送信成功をシミュレート、false → 送信失敗をシミュレート
// 両方の動作を確認したら、この定数と下の setTimeout を実際の送信API呼び出しに差し替えてください
var SIMULATE_SUCCESS = true;
var FAKE_SUBMIT_MS   = 1500;

// 二重送信防止用のフラグ(disabled属性と併用する)
var isSubmitting = false;
// デモ専用:実際に送信処理が実行された回数
var submitCount = 0;

var submitLockForm  = document.getElementById('submitLockForm');
var submitLockBtn   = document.getElementById('submitLockBtn');
var btnText         = submitLockBtn.querySelector('.submit-lock-btn-text');
var btnSpinner      = submitLockBtn.querySelector('.submit-lock-btn-spinner');
var btnCheck        = submitLockBtn.querySelector('.submit-lock-btn-check');
var errorMessage    = document.getElementById('submitLockError');
var countDisplay    = document.getElementById('submitLockCount');

// SVG の表示切替(element.hidden は SVGElement では動作しないため style.display を使う)
function showSvg(el) { el.style.display = 'inline'; }
function hideSvg(el) { el.style.display = 'none'; }

function setLoading() {
  // 即座に・同期的にロックする(非同期処理が始まる前にガードを立てるのがポイント)
  isSubmitting = true;
  submitLockBtn.disabled = true;
  btnText.textContent = '送信中...';
  hideSvg(btnCheck);
  showSvg(btnSpinner);
}

function setSuccess() {
  submitCount++;
  countDisplay.textContent = submitCount;
  hideSvg(btnSpinner);
  btnText.textContent = '送信完了';
  showSvg(btnCheck);
  // 成功時はボタンを無効のまま維持する(isSubmitting も true のまま)
}

function setFailure() {
  submitCount++;
  countDisplay.textContent = submitCount;
  hideSvg(btnSpinner);
  hideSvg(btnCheck);
  btnText.textContent = '送信する';
  errorMessage.hidden = false;
  // 失敗時は再送信できるようにロックを解除する
  isSubmitting = false;
  submitLockBtn.disabled = false;
}

// クリックイベントではなく submit イベントでガードする。
// テキスト入力欄でのEnterキー送信も同じ submit イベントとして発火するため、
// ここ1箇所でガードすればクリック・Enterキーのどちらの経路でも二重送信を防げる
submitLockForm.addEventListener('submit', function (e) {
  e.preventDefault();

  // 送信処理中は以降のクリック・Enter送信を無視する(二重送信防止の本体)
  if (isSubmitting) return;

  errorMessage.hidden = true;
  setLoading();

  // 疑似レスポンス(実際は fetch 等の送信APIに差し替える)
  setTimeout(function () {
    if (SIMULATE_SUCCESS) {
      setSuccess();
    } else {
      setFailure();
    }
  }, FAKE_SUBMIT_MS);
});

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や要件変更など、条件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# 送信ボタン(二重送信防止・ローディング付き)作成依頼

## 概要
名前・メールアドレスの入力欄と送信ボタンだけのシンプルなフォームを作成してください。
送信ボタンをクリックした瞬間にボタンを無効化してローディング表示に切り替え、処理中の連打・二重クリックによる二重送信を防止する実装が目的です。

## 要件
- 入力項目は「名前」「メールアドレス」の2つのみ
- 「送信する」ボタンをクリックすると、即座にボタンを disabled にしてテキストを「送信中...」に変更し、スピナーを表示する
- ローディング中にボタンを連打・ダブルクリックしても、送信処理が複数回実行されないようにする(クリックイベントではなくフォームの送信イベントでガードし、Enterキーによる送信も同様に防止すること)
- 約1.5秒後、送信結果(成功/失敗)に応じて以下のように分岐する
  - 成功時:ボタンをチェックマークアイコン+「送信完了」表示に変更し、ボタンは無効のまま維持する
  - 失敗時:フォーム内に「送信に失敗しました。もう一度お試しください。」というエラーメッセージを表示し、ボタンを「送信する」表示に戻して再度クリックできるようにする
- 動作確認用に、実際に送信処理が何回実行されたかを表示する「送信回数カウンター」をデモ用に用意する(実際の実装では省略してよい旨をコメントに明記する)

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要(最小幅 320px)

## 動作詳細
実際の通信処理は setTimeout で1.5秒後に完了するダミー処理として実装してください。二重送信防止のガードは、ボタンの disabled 属性だけでなく、フォームの送信処理中かどうかを示すJSのフラグ変数でも制御し、両方で二重にガードしてください。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。