送信ボタンやAjax処理でユーザーが同じボタンを連打すると、重複送信や二重実行などの不具合につながります。JavaScriptでクリックを一時的に無効化したり、一定時間に最大1回だけ実行する(throttle)ことで防げます。この記事では、確実に連打を止めるための実装を用途別に整理します。
disabledでボタンを無効化する基本- 非同期処理を
fetch+finallyで確実に解除する書き方 - 見た目を変えないフラグ方式
- 一定時間に最大1回だけ実行する汎用
throttle関数 throttleとdebounceの違いと使い分け- 複数ボタンへまとめて適用する方法
disabled = true、完了時に finally で false に戻します。「一定時間に1回まで」に制限したいなら throttle 関数でラップします。固定の setTimeout で解除するのは避け、実処理の完了に合わせて解除するのが安全です。連打防止の2つの考え方
- 処理中は無効化:1回の処理が終わるまでボタンを押せなくする(送信・保存など)
- レート制限(throttle):何回押されても一定時間に最大1回だけ実行する(スクロール連動・連続アクションなど)
フォーム送信の二重送信を防ぐ専用の対策(ローディング表示・
fetch・サーバー側対策まで)は送信ボタンの二重クリックを防止する方法でまとめています。逆に「N回クリックされたら発火させたい」場合は指定回数クリックで処理を実行する方法を参照してください。この記事は連打そのものを止める(無効化・throttle)側の解説です。基本:disabled で無効化する
最もシンプルなのは、クリック直後にボタンへ disabled を付ける方法です。処理が一瞬で終わる(同期的な)ケースに向きます。
const btn = document.getElementById("submit-btn");
btn.addEventListener("click", () => {
btn.disabled = true; // 1回目で無効化、以降は反応しない
console.log("送信処理を実行");
});
非同期処理は finally で確実に解除する
送信が fetch などの非同期処理の場合、処理が終わったタイミングでボタンを戻す必要があります。ここで固定の setTimeout で戻すのは危険です。通信が想定より遅いと処理中に再び押せてしまい、速いと無駄に待たせます。try / finally を使えば、成功・失敗どちらでも確実に解除できます。
const btn = document.getElementById("submit-btn");
btn.addEventListener("click", async () => {
if (btn.disabled) return; // 念のため二重実行ガード
btn.disabled = true;
try {
const res = await fetch("/api/submit", { method: "POST" });
if (!res.ok) throw new Error("送信失敗");
console.log("送信完了");
} catch (e) {
console.error(e);
} finally {
btn.disabled = false; // 成功・失敗にかかわらず必ず戻す
}
});
setTimeout で解除しないsetTimeout(() => btn.disabled = false, 3000) のように決め打ちの秒数で戻すと、実処理が3秒を超えれば処理中に押せてしまい、3秒未満なら無駄に待たせます。非同期処理の完了に合わせて解除するのが正解です。setTimeout の基本はsetTimeout の使い方を参照してください。見た目を変えないフラグ方式
ボタンの見た目(グレーアウト)を変えたくない場合は、disabled の代わりに状態変数(フラグ)で制御します。こちらも解除は finally で行うのが安全です。
const btn = document.getElementById("action-btn");
let processing = false;
btn.addEventListener("click", async () => {
if (processing) {
console.log("処理中です");
return;
}
processing = true;
try {
await doSomething(); // 実際の処理
} finally {
processing = false; // 完了したら再度クリック可能に
}
});
一定時間に最大1回だけ実行する(throttle)
「押し続けても一定時間に1回しか実行させない」レート制限が throttle です。直近の実行時刻を記録し、間隔内の呼び出しを無視します。汎用関数にしておけば、どんな処理にも使い回せます。
// fn を「interval ミリ秒に最大1回」に制限する
function throttle(fn, interval) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last < interval) return; // 間隔内は無視
last = now;
fn.apply(this, args);
};
}
const btn = document.getElementById("action-btn");
// 1秒に1回までしか実行されない
btn.addEventListener("click", throttle(() => {
console.log("実行(連打しても1秒に1回)");
}, 1000));
送信・保存のように「終わるまで1回」なら
disabled + finally、連続アクション・スクロール連動のように「一定間隔で間引く」なら throttle が向いています。クリックイベントの基礎はクリックイベントの設定方法も参考にしてください。throttle と debounce の違い
連打対策でよく比較される2つの違いを整理します。
| 手法 | 動作 | 向いている用途 |
|---|---|---|
| throttle | 一定間隔で最大1回実行(間引く) | 連打防止・スクロール/リサイズ連動 |
| debounce | 最後の呼び出しからN秒後に1回だけ実行 | 検索入力の絞り込み・自動保存 |
ボタンの連打防止には、押すたびに反応しつつ間引く throttle が基本です。debounce は「入力が止まってから実行」したいときに使います。両者の実装(leading/trailing・requestAnimationFrame・Lodash)はdebounceとthrottleの違いと実装方法で詳しく解説しています。
複数のボタンにまとめて適用する
throttle を各ボタンに対して呼べば、ボタンごとに独立した状態(クロージャ)で連打防止が効きます。
document.querySelectorAll(".throttled").forEach((btn) => {
btn.addEventListener("click", throttle(() => {
console.log(btn.dataset.action + " を実行");
}, 1000));
});
JavaScriptの連打防止はUX向上のための第一防衛線です。通信の行き違いや改ざんで二重リクエストが届くことはあるため、サーバー側でも重複を弾く(ワンタイムトークン・冪等キーなど)設計を併用してください。
よくある質問(FAQ)
click で btn.disabled = true にし、処理完了時に false へ戻します。非同期処理なら try / finally の finally で戻すと、成功・失敗どちらでも確実に解除できます。「一定間隔で間引く」なら throttle 関数を使います。finally で)解除するのが安全です。throttle は一定間隔で最大1回実行(間引く)、debounce は最後の呼び出しからN秒後に1回だけ実行します。連打防止には throttle、検索入力の絞り込みには debounce が向いています。disabled(グレーアウト)の代わりに、状態変数(フラグ)で「処理中は無視」を実装します。こちらも解除は finally で行うと確実です。fetch・サーバー側対策)は送信ボタンの二重クリックを防止する方法に詳しくまとめています。フロント側の無効化と、サーバー側の重複排除を併用してください。まとめ
- 処理中は無効化:
disabled = true→ 完了時にfinallyでfalse - 固定
setTimeoutで解除しない:実処理の完了に合わせる - 見た目を変えない:フラグ方式(解除はやはり
finally) - 一定間隔で間引く:汎用
throttle関数でラップ - throttle と debounce:連打防止は throttle、入力絞り込みは debounce
- サーバー側対策も併用:フロントの連打防止は第一防衛線
用途に合わせて「無効化」か「throttle」かを選び、非同期処理では必ず finally で解除して、確実に連打を止めましょう。

