【JavaScript】送信ボタンの二重クリックを防止する方法|disabled・ローディング表示・fetch + finally・サーバー側対策まで解説

フォームの送信ボタンを連続でクリックすると、同じデータが複数回送信されてデータの重複登録二重決済が発生する危険があります。この「二重送信(2 度押し)」問題は、JavaScript でボタンを無効化することに加え、サーバー側の対策も組み合わせて防止するのが鉄則です。

この記事でわかること
・disabled 属性でボタンを無効化する基本
・ローディングスピナーでユーザーに処理中であることを伝える方法
・fetch + try/catch/finally で非同期処理の完了後に再有効化
・form の submit イベントでフォーム全体を制御する方法
・AbortController で重複リクエストをキャンセルする方法
・CSS pointer-events: none と aria-disabled の使い分け
・サーバー側の冪等性トークンによる二重送信防止
スポンサーリンク

二重クリック防止の方法一覧

方法 特徴 推奨場面
disabled 属性 最もシンプル・確実 あらゆるフォーム送信
フラグ変数 disabled にしたくない場合 ボタンの見た目を維持したい場合
fetch + finally 非同期完了後に自動復帰 Ajax / API 送信
AbortController 重複リクエスト自体をキャンセル fetch ベースの送信
サーバー側トークン JS 無効でも防止可能 決済・重要データの登録

disabled 属性でボタンを無効化する(基本)

JavaScript
const form = document.getElementById("myForm");
const btn = form.querySelector('[type="submit"]');

form.addEventListener("submit", (e) => {
  // ボタンを即座に無効化
  btn.disabled = true;
  btn.textContent = "送信中...";
});

これだけで連続クリックの大半を防げます。disabled なボタンはクリックイベントが発火しないため、二重送信を確実にブロックします。

disabled なボタンはフォーム送信時に値が送られません。サーバー側でボタンの name / value を使って処理を分岐している場合は注意してください。

ローディングスピナーで処理中を伝える

ボタンを無効化するだけでなく、ローディングスピナーを表示することでユーザーに「処理中」であることを視覚的に伝えられます。

HTML
<button type="submit" id="submitBtn">
  <span class="btn-text">送信する</span>
  <span class="btn-spinner" hidden></span>
</button>
CSS
.btn-spinner {
  display: inline-block;
  width: 16px;
  height: 16px;
  border: 2px solid #fff;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
  vertical-align: middle;
  margin-left: 8px;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}
JavaScript
const btn = document.getElementById("submitBtn");
const btnText = btn.querySelector(".btn-text");
const spinner = btn.querySelector(".btn-spinner");

function setLoading(loading) {
  btn.disabled = loading;
  btnText.textContent = loading ? "送信中..." : "送信する";
  spinner.hidden = !loading;
}

fetch + try/catch/finally で非同期処理を安全に制御する

Ajax 送信では、finally ブロックでボタンを再有効化することで、成功時もエラー時も確実に復帰できます。

JavaScript
const form = document.getElementById("myForm");

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  setLoading(true);

  try {
    const res = await fetch("/api/submit", {
      method: "POST",
      body: new FormData(form)
    });

    if (!res.ok) throw new Error(`HTTP ${res.status}`);

    const data = await res.json();
    showSuccess("送信が完了しました");

  } catch (error) {
    showError("送信に失敗しました。再度お試しください。");
    console.error(error);

  } finally {
    // 成功でもエラーでも必ず実行される
    setLoading(false);
  }
});
finally はエラーが発生しても必ず実行されるため、ボタンの再有効化に最適です。finally を使わないと、エラー発生時にボタンが無効のまま固まってしまいます。

フラグ変数で制御する

disabled でボタンの見た目が変わるのを避けたい場合は、フラグ変数で制御します。

JavaScript
let isSubmitting = false;

form.addEventListener("submit", async (e) => {
  e.preventDefault();

  if (isSubmitting) return; // 送信中なら無視
  isSubmitting = true;

  try {
    await fetch("/api/submit", { method: "POST", body: new FormData(form) });
  } finally {
    isSubmitting = false;
  }
});
フラグ変数だけではボタンのクリック自体は発火するため、ユーザーが「反応がない」と感じて何度もクリックする可能性があります。disabled + ローディング表示の方が UX 的に推奨です。

AbortController で重複リクエストをキャンセルする

前のリクエストが完了する前に再送信された場合、前のリクエストをキャンセルして最新のリクエストだけを有効にする方法です。

JavaScript
let abortController = null;

form.addEventListener("submit", async (e) => {
  e.preventDefault();

  // 前のリクエストがあればキャンセル
  if (abortController) {
    abortController.abort();
  }

  abortController = new AbortController();

  try {
    const res = await fetch("/api/submit", {
      method: "POST",
      body: new FormData(form),
      signal: abortController.signal
    });
    console.log("送信成功:", await res.json());
  } catch (err) {
    if (err.name === "AbortError") {
      console.log("前のリクエストをキャンセルしました");
    } else {
      console.error("送信エラー:", err);
    }
  } finally {
    abortController = null;
  }
});
AbortController はリクエスト自体をキャンセルするため、サーバーへの不要なリクエストを減らせます。検索フォームのインクリメンタルサーチにも有効なテクニックです。

CSS pointer-events: none と aria-disabled の使い分け

disabled 属性はボタンの見た目を変えるだけでなく、フォーム送信時にボタンの値が送られなくなります。見た目を維持したい場合の代替手段です。

方法 クリック無効 キーボード無効 フォーム値 見た目
disabled 送られない グレーアウト
pointer-events: none ×(Tab+Enter は有効) 送られる 変わらない
aria-disabled="true" × JS で別途制御必要 × JS で別途制御必要 送られる 変わらない
aria-disabled パターン
const btn = document.getElementById("submitBtn");

// 無効化
btn.setAttribute("aria-disabled", "true");
btn.style.opacity = "0.6";
btn.style.cursor = "not-allowed";

// クリック時にチェック
btn.addEventListener("click", (e) => {
  if (btn.getAttribute("aria-disabled") === "true") {
    e.preventDefault();
    return;
  }
  // 送信処理
});

サーバー側の対策(冪等性トークン)

JavaScript による防止はクライアント側の対策に過ぎません。JS が無効化されている場合や、ネットワーク遅延でリクエストが重複する場合にはサーバー側でも二重送信を防止する必要があります。

冪等性トークンの仕組み

ステップ 処理
1 サーバーがフォーム表示時にユニークなトークンを発行
2 フォームに hidden フィールドとしてトークンを埋め込む
3 送信時にトークンをサーバーに送る
4 サーバーはトークンの初回使用のみ処理し、2 回目以降は拒否
HTML(hidden フィールド)
<form method="POST" action="/api/order">
  <input type="hidden" name="idempotency_token" value="abc123-unique-token">
  <!-- フォームフィールド -->
  <button type="submit">注文を確定する</button>
</form>
決済や注文確定など金銭が絡む処理では、クライアント側の disabled だけに頼らず、必ずサーバー側でも冪等性チェックを実装してください。

実務で使える完成版コード

汎用的なフォーム送信関数
async function submitForm(form, url) {
  const btn = form.querySelector('[type="submit"]');
  const originalText = btn.textContent;

  btn.disabled = true;
  btn.textContent = "送信中...";

  try {
    const res = await fetch(url, {
      method: "POST",
      body: new FormData(form)
    });

    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return await res.json();

  } catch (error) {
    alert("送信に失敗しました。再度お試しください。");
    throw error;

  } finally {
    btn.disabled = false;
    btn.textContent = originalText;
  }
}

// 使用例
document.getElementById("contactForm").addEventListener("submit", async (e) => {
  e.preventDefault();
  const data = await submitForm(e.target, "/api/contact");
  console.log("送信完了:", data);
});

関連記事

よくある質問

Q最も確実な二重送信防止の方法は?
Aクライアント側の disabled + サーバー側の冪等性トークンの二段構えが最も確実です。disabled だけでは JS 無効時やネットワーク遅延に対応できず、サーバー側だけでは UX が悪くなります。
Qdisabled にするとフォームの値が送信されなくなります。
Adisabled なフォーム要素の値はサーバーに送信されません。ボタンの name/value をサーバーで使っている場合は、disabled の代わりに aria-disabled + JS でのクリック抑止を検討してください。
Qエラー時にボタンが無効のまま固まってしまいます。
Afinally ブロックで btn.disabled = false を実行してください。finally は成功時もエラー時も必ず実行されるため、どのような状況でもボタンが復帰します。
Qボタンの見た目を変えずに無効化したいです。
Adisabled 属性ではなく、フラグ変数 + pointer-events: none + aria-disabled="true" を使います。ただしキーボード操作(Tab → Enter)は pointer-events では防げないため、JS 側でもチェックが必要です。
QReact でフォームの二重送信を防ぐには?
Astate で isSubmitting を管理し、ボタンの disabled に反映します。const [isSubmitting, setIsSubmitting] = useState(false); で管理し、送信開始時に true、finally で false に戻します。

まとめ

送信ボタンの二重クリック防止方法を整理しました。

  • 基本: btn.disabled = true + ローディング表示
  • 非同期処理: finally で確実にボタンを復帰
  • リクエストキャンセル: AbortController で重複リクエスト自体をキャンセル
  • 見た目維持: aria-disabled + pointer-events: none
  • サーバー側: 冪等性トークンで JS 無効でも防止

フォーム送信の二重防止はクライアントとサーバーの二段構えが鉄則です。特に決済や注文確定など金銭が絡む処理では、必ずサーバー側でも対策を実装してください。