JavaScript | 非同期処理:実務での非同期制御 - 多重送信防止

JavaScript JavaScript
スポンサーリンク

多重送信防止を一言でいうと

多重送信防止は、
「ユーザーが同じ操作を連打しても、サーバーには“1 回分だけ”しか飛ばさないようにする仕組み」 です。

非同期処理(特にフォーム送信やボタン押下)では、
ユーザーが不安になってボタンを何度も押すことがあります。

その結果、こうなります。

同じ注文が 2 回登録される
同じ決済が 2 回走る
同じコメントが 3 つ並ぶ

これを防ぐのが「多重送信防止」です。
実務では、かなり重要な“安全装置”です。


なぜ非同期処理で多重送信が起きやすいのか

「押したのに何も起きない時間」があるから

同期処理なら、
ボタンを押した瞬間に画面が切り替わったり、
すぐに結果が返ってきたりします。

でも非同期処理では、

ボタンを押す
→ API を呼ぶ
→ サーバーが処理する
→ 結果が返ってくる

という流れの間に「待ち時間」があります。

このとき、ユーザーはこう感じがちです。

「押せてない?」「フリーズした?」「もう一回押した方がいい?」

その結果、
同じボタンを 2 回、3 回と押してしまう。
これが多重送信の典型パターンです。

ブラウザは「連打」を止めてくれない

ブラウザは、
「同じボタンを短時間に何度も押したから、2 回目以降は無視しよう」
なんて気を利かせてはくれません。

イベントリスナーを付けている限り、
クリックされるたびにハンドラが呼ばれます。

だからこそ、
「同じ処理を同時に何度も走らせない」
という制御を、開発者側が意識して書く必要があります。


一番基本の対策:ボタンを「処理中は押せなくする」

送信中は disabled にする

一番シンプルで、かつ実務でもよく使うのが
「処理中はボタンを無効化する」 という方法です。

HTML 例:

<button id="save-button">保存</button>

JavaScript 例:

const saveButton = document.getElementById("save-button");

async function onClickSave() {
  // すでに無効なら何もしない(念のため)
  if (saveButton.disabled) {
    return;
  }

  saveButton.disabled = true; // ここから多重送信防止スタート

  try {
    await saveForm(); // 非同期処理(API 呼び出しなど)
    alert("保存しました");
  } catch (err) {
    console.error(err);
    alert("保存に失敗しました");
  } finally {
    saveButton.disabled = false; // 成功でも失敗でも必ず元に戻す
  }
}

saveButton.addEventListener("click", onClickSave);
JavaScript

流れを言葉で追うとこうです。

ボタンがクリックされる
→ まず disabled をチェック(すでに true なら何もしない)
disabled = true にして、以降のクリックを受け付けない
→ 非同期処理(保存)を実行
→ 成功でも失敗でも finally で disabled = false に戻す

ここが重要です。
「ボタンを無効化する」と「必ず元に戻す」をセットで書く。
そして“どんな結果でも必ず戻す”ために finally を使う。

これだけで、
「保存中に連打しても、2 回目以降は何も起きない」状態になります。


ローディング表示と組み合わせる(実務での定番)

見た目でも「今は押せない」ことを伝える

単に disabled にするだけでも多重送信は防げますが、
ユーザーから見ると「なぜ押せないのか」が分かりません。

そこで、
「ボタンを無効化しつつ、見た目も“処理中”に変える」
のが実務での定番です。

HTML 例:

<button id="save-button">
  <span class="label">保存</span>
  <span class="spinner" style="display: none;">...</span>
</button>

JavaScript 例:

const saveButton = document.getElementById("save-button");

function setSaveButtonLoading(isLoading) {
  saveButton.disabled = isLoading;
  saveButton.querySelector(".label").style.display = isLoading ? "none" : "inline";
  saveButton.querySelector(".spinner").style.display = isLoading ? "inline" : "none";
}

async function onClickSave() {
  if (saveButton.disabled) {
    return;
  }

  setSaveButtonLoading(true);

  try {
    await saveForm();
    showSuccessMessage("保存しました");
  } catch (err) {
    console.error(err);
    showErrorMessage("保存に失敗しました");
  } finally {
    setSaveButtonLoading(false);
  }
}

saveButton.addEventListener("click", onClickSave);
JavaScript

これで、

ボタンは押せない
ラベルが消えてスピナーが出る
「今このボタンが処理中なんだな」と一目で分かる

という状態になります。

ここが重要です。
多重送信防止は「技術的に防ぐ」だけでなく、
“ユーザーに今の状態を伝える”ことで、そもそも連打したくならない UI にする、
という視点もセットで考えると強いです。


フラグで制御するパターン(ボタン以外にも使える)

isSubmitting フラグで「今は受け付けない」を表現する

ボタンの disabled だけでなく、
「今は送信処理中だから、新しい送信は受け付けない」
というフラグを持つパターンもよく使います。

let isSubmitting = false;

async function onSubmit() {
  if (isSubmitting) {
    // すでに送信中なら何もしない
    return;
  }

  isSubmitting = true;

  try {
    await saveForm();
    showSuccessMessage("保存しました");
  } catch (err) {
    console.error(err);
    showErrorMessage("保存に失敗しました");
  } finally {
    isSubmitting = false;
  }
}
JavaScript

このパターンの良いところは、

ボタンだけでなく、
キーボードショートカットや他のイベントからの呼び出しにも効く

という点です。

例えば、
「Enter キーでも送信できる」ようなフォームでは、
ボタンとキーイベントの両方から onSubmit を呼ぶことがあります。

そのとき、
isSubmitting フラグで制御しておけば、
どの経路から来ても「2 回目以降は無視」が効きます。


「完了するまで二度と押せない」か「途中でキャンセルできる」か

完全にロックするか、キャンセルを許すか

多重送信防止の設計で、
もう一つ考えたいのが「キャンセル」の扱いです。

例えば、
「重い検索処理」を走らせているときに、
ユーザーが「やっぱり条件を変えたい」と思うことがあります。

このとき、

検索ボタンを完全に無効化してしまうと、
ユーザーは結果が返るまで何もできない

一方で、
「新しい検索が来たら前の検索をキャンセルして、最新の検索だけ有効にする」
という設計もありえます。

これは少し高度な話ですが、
実務ではよく出てきます。

AbortController を使った「キャンセル可能な多重送信防止」(入口だけ)

JavaScript には AbortController という仕組みがあり、
fetch を途中でキャンセルできます。

ざっくりイメージだけ示すと、こんな感じです。

let currentAbortController = null;

async function search(query) {
  // すでに進行中の検索があればキャンセル
  if (currentAbortController) {
    currentAbortController.abort();
  }

  const controller = new AbortController();
  currentAbortController = controller;

  try {
    const res = await fetch("/api/search?q=" + encodeURIComponent(query), {
      signal: controller.signal,
    });
    const data = await res.json();
    renderSearchResult(data);
  } catch (err) {
    if (err.name === "AbortError") {
      console.log("前の検索はキャンセルされました");
      return;
    }
    console.error(err);
    showErrorMessage("検索に失敗しました");
  } finally {
    // 自分の検索が終わったら、自分のコントローラならクリア
    if (currentAbortController === controller) {
      currentAbortController = null;
    }
  }
}
JavaScript

これは、

「前のリクエストはキャンセルし、常に“最後の操作だけ”を有効にする」
という意味での「多重送信防止」です。

ここが重要です。
多重送信防止は「全部を拒否する」だけでなく、
“古いリクエストを無効にして、新しいリクエストだけ通す”
という設計も含めて考えられる、ということを頭の片隅に置いておくと、
実務での選択肢が広がります。


サーバー側の多重送信対策との関係

クライアントだけで防げないケースもある

フロントエンドで多重送信防止をしても、
ネットワークの遅延やブラウザの再送などで、
サーバー側に同じリクエストが複数届くことはありえます。

特に、
「決済」「注文」「会員登録」などの重要処理では、
サーバー側でも「同じ内容のリクエストを二重に処理しない」工夫をします。

例えば、

リクエストごとに一意な ID(トークン)を付ける
サーバー側で「このトークンはもう処理済みか」をチェックする

といった仕組みです。

フロントエンドの多重送信防止は、
「ユーザーの誤操作を減らす」「サーバーへの無駄な負荷を減らす」
という意味でとても重要ですが、
「絶対に二重にならない保証」はサーバー側とセットで考えるものだ、
という感覚も持っておくと良いです。


初心者として「多重送信防止」で本当に押さえてほしいこと

非同期処理では、
「押したのに何も起きない時間」があるから、
ユーザーはボタンを連打しがち。

多重送信防止の基本は、
「処理中は同じ操作を受け付けない」 こと。

一番シンプルで強力なのは、
ボタンを disabled にする+処理完了後に必ず戻す、というパターン。
try ... finally で「必ず戻す」を保証するのが鉄板。

ボタンの見た目も「処理中」に変えて、
ユーザーに「今は待ち時間だ」と伝えると、
そもそも連打したくならない UI になる。

ボタンだけでなく、
isSubmitting フラグで「今は受け付けない」を表現すると、
複数の経路(クリック、Enter キーなど)からの呼び出しにも対応できる。

そして、
「全部拒否する」のか「古いリクエストだけ無効にして新しいものを通す」のか、
どんな挙動がユーザーにとって自然かを考えることが、
実務での非同期制御の“設計”になっていきます。

今書いているフォームやボタンの中で、
「これ、連打されたら困るな…」と思う場所があったら、
まずは disabled + finally のパターンを一つ入れてみてください。
それだけで、あなたのアプリは一段「壊れにくい」ものになります。

タイトルとURLをコピーしました