T-CREATOR

htmx で二重送信が起きる/起きない問題の完全対処:trigger と disable パターン

htmx で二重送信が起きる/起きない問題の完全対処:trigger と disable パターン

Web アプリケーション開発において、フォーム送信やボタンクリック時の「二重送信」は古くて新しい問題です。htmx を使えば少ないコードで動的な UI が実現できる一方、デフォルトの挙動を理解していないと、意図せず二重送信が発生したり、逆に期待通りに防げなかったりします。

本記事では、htmx における二重送信問題の 根本原因発生パターン、そして trigger と disable を活用した完全対処法 まで、初心者の方にもわかりやすく解説します。

背景

htmx とは

htmx は、HTML 属性だけで Ajax リクエストを実現できる軽量な JavaScript ライブラリです。従来の JavaScript フレームワークと比べて、以下のような特徴があります。

  • HTML 中心の記述: hx-gethx-post などの属性でリクエストを定義
  • サーバー駆動: サーバーから返される HTML を直接 DOM に挿入
  • 軽量: 約 14KB の小さなライブラリサイズ

以下の図は、htmx の基本的な動作フローを示しています。

mermaidflowchart LR
  user["ユーザー"] -->|クリック/入力| elem["HTML 要素<br/>(hx-* 属性)"]
  elem -->|イベント発火| htmx["htmx ライブラリ"]
  htmx -->|HTTP リクエスト| server["サーバー"]
  server -->|HTML レスポンス| htmx
  htmx -->|DOM 更新| page["ページ"]
  page -->|表示| user

図で理解できる要点:

  • htmx はイベント(クリック、入力など)をトリガーに自動的にリクエストを送信
  • サーバーから返された HTML を直接 DOM に反映
  • JavaScript コードを書かずに動的な UI を実現

二重送信問題の一般的な背景

二重送信とは、ユーザーの 1 回の操作に対して、意図せず複数回のリクエストがサーバーに送られてしまう現象です。これにより以下のような問題が発生します。

  • データの重複登録: 同じ注文が 2 回処理される
  • サーバー負荷の増加: 不要なリクエストでリソースを消費
  • UX の低下: ユーザーが混乱する

従来の Web アプリケーションでは、JavaScript で手動制御したり、サーバー側でトークンを使った制御を行ったりしていました。htmx でも同様の問題が発生しますが、独自の解決策が用意されています。

課題

htmx で二重送信が「起きる」ケース

htmx を使っていると、以下のようなケースで二重送信が発生することがあります。

ケース 1: ボタンの連打

最もよくあるのが、送信ボタンを何度もクリックしてしまうケースです。

html<!-- 問題のあるコード例 -->
<button hx-post="/api/submit" hx-target="#result">
  送信
</button>

このコードでは、ユーザーがボタンを素早く 2 回クリックすると、2 つのリクエストが送信されてしまいます。

ケース 2: フォーム送信とボタンのイベント重複

フォームと送信ボタンの両方に htmx 属性を付けた場合、イベントが重複して発火することがあります。

html<!-- イベントが重複する例 -->
<form hx-post="/api/submit">
  <input type="text" name="username" />
  <button type="submit" hx-post="/api/submit">送信</button>
</form>

上記の場合、フォームの submit イベントとボタンのクリックイベントが両方発火し、2 回リクエストが送られる可能性があります。

ケース 3: カスタムイベントとの競合

htmx は hx-trigger でカスタムイベントを設定できますが、イベントの発火タイミングが重複すると二重送信につながります。

html<!-- カスタムイベントでの重複例 -->
<button
  hx-post="/api/submit"
  hx-trigger="click, customEvent"
>
  送信
</button>

click イベントで customEvent も発火するような実装だと、1 回のクリックで 2 回リクエストが送られます。

htmx で二重送信が「起きない」はずなのに起きるケース

一方で、「htmx は自動的に二重送信を防ぐ」と思い込んで実装すると、意図しない挙動に遭遇することがあります。

デフォルトの挙動への誤解

htmx はリクエスト中に 同じ要素からの再リクエストを自動的にブロック します。しかし、これは以下の条件を満たす場合のみです。

  • 同一の要素 からのリクエストであること
  • 前のリクエストが完了していない こと

以下の図は、htmx のデフォルト動作における二重送信防止の仕組みを示しています。

mermaidstateDiagram-v2
  [*] --> Idle: 初期状態
  Idle --> Requesting: クリック①
  Requesting --> Idle: レスポンス受信

  Requesting --> Requesting: クリック②<br/>(ブロックされる)

  note right of Requesting
    リクエスト中は同じ要素からの
    再リクエストを自動ブロック
  end note

図で理解できる要点:

  • リクエスト中(Requesting 状態)は同一要素からの再クリックを無視
  • レスポンス受信後(Idle 状態)に戻ると再度リクエスト可能
  • ただし、この仕組みは「同一要素」に限定される

複数要素での同時リクエスト

別々の要素から同時にリクエストが送られると、htmx のデフォルト防止機能は働きません。

html<!-- 別要素からの同時リクエストは防げない -->
<button id="btn1" hx-post="/api/submit">ボタン 1</button>
<button id="btn2" hx-post="/api/submit">ボタン 2</button>

解決策

trigger パターンによる制御

hx-trigger 属性を使うことで、リクエストの発火タイミングを細かく制御できます。

once 修飾子で 1 回限りの実行

最も単純な解決策は、once 修飾子を使って 1 回だけ実行させる方法です。

html<!-- once 修飾子の使用 -->
<button hx-post="/api/submit" hx-trigger="click once">
  送信(1回のみ)
</button>

この設定により、ボタンは最初のクリックでのみリクエストを送信し、以降のクリックは無視されます。

適用シーン: 登録処理、決済処理など、絶対に 1 回しか実行してはいけない操作

throttle 修飾子で連打を制限

一定時間内の連打を防ぐには、throttle 修飾子を使います。

html<!-- throttle で 1 秒間に 1 回まで制限 -->
<button
  hx-post="/api/submit"
  hx-trigger="click throttle:1s"
>
  送信
</button>

throttle:1s と指定すると、クリックから 1 秒間は次のリクエストを送信しません。

適用シーン: 検索フィルター、ページネーションなど、短時間の連続操作が想定される UI

debounce 修飾子で入力完了を待つ

入力フォームなど、ユーザーの操作が落ち着いてからリクエストを送りたい場合は debounce を使います。

html<!-- 入力停止後 500ms でリクエスト -->
<input
  type="text"
  name="search"
  hx-get="/api/search"
  hx-trigger="keyup changed debounce:500ms"
  hx-target="#results"
/>

debounce:500ms により、最後の入力から 500 ミリ秒経過後にリクエストが送られます。

適用シーン: オートコンプリート、インクリメンタルサーチ

以下の図は、throttledebounce の違いを視覚化したものです。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Throttle as throttle:1s
  participant Debounce as debounce:500ms

  User->>Throttle: クリック①
  Throttle->>Throttle: リクエスト送信
  User->>Throttle: クリック② (0.3秒後)
  Note right of Throttle: ブロック (1秒未満)
  User->>Throttle: クリック③ (1.2秒後)
  Throttle->>Throttle: リクエスト送信

  User->>Debounce: 入力①
  Note right of Debounce: 待機開始
  User->>Debounce: 入力② (0.2秒後)
  Note right of Debounce: タイマーリセット
  User->>Debounce: 入力③ (0.3秒後)
  Note right of Debounce: タイマーリセット
  Note right of Debounce: 0.5秒経過
  Debounce->>Debounce: リクエスト送信

図で理解できる要点:

  • throttle: 一定間隔で定期的にリクエストを送る(連打の頻度を下げる)
  • debounce: 操作が落ち着いてから 1 回だけリクエストを送る(入力完了を待つ)

disable パターンによる制御

hx-disabled-elt でボタンを無効化

リクエスト中にボタンを無効化することで、視覚的にも機能的にも二重送信を防げます。

html<!-- リクエスト中はボタンを無効化 -->
<button hx-post="/api/submit" hx-disabled-elt="this">
  送信
</button>

hx-disabled-elt="this" により、リクエスト開始時に disabled 属性が付与され、レスポンス受信後に自動的に解除されます。

複数要素を同時に無効化

フォーム全体を無効化したい場合は、セレクタを使って複数要素を指定できます。

html<!-- フォーム内の全入力要素を無効化 -->
<form hx-post="/api/submit">
  <input type="text" name="username" />
  <input type="email" name="email" />
  <button type="submit" hx-disabled-elt="closest form">
    送信
  </button>
</form>

closest form により、最も近い <form> 要素内の全入力要素が無効化されます。

CSS でローディング状態を表現

htmx-request クラスを使うと、リクエスト中の視覚的フィードバックを提供できます。

html<!-- リクエスト中の見た目を変更 -->
<button hx-post="/api/submit" hx-disabled-elt="this">
  送信
</button>
css/* リクエスト中のスタイル */
.htmx-request {
  opacity: 0.5;
  cursor: wait;
}

/* ボタンが無効化された時のスタイル */
button:disabled {
  background-color: #cccccc;
  cursor: not-allowed;
}

htmx は自動的に .htmx-request クラスをリクエスト中の要素に付与するため、CSS で簡単にローディング状態を表現できます。

組み合わせパターン:最強の二重送信対策

実務では、triggerdisable を組み合わせることで、より堅牢な二重送信対策が実現できます。

html<!-- trigger + disable の組み合わせ -->
<button
  hx-post="/api/submit"
  hx-trigger="click throttle:1s"
  hx-disabled-elt="this"
  hx-indicator="#spinner"
>
  <span>送信</span>
  <span id="spinner" class="htmx-indicator">
    処理中...
  </span>
</button>
css/* インジケーターの制御 */
.htmx-indicator {
  display: none;
}

.htmx-request .htmx-indicator {
  display: inline;
}

.htmx-request span:not(.htmx-indicator) {
  display: none;
}

この実装では、以下の多層防御が実現されています。

  1. throttle: 1 秒間に 1 回までに制限
  2. disabled: リクエスト中はボタンを物理的に無効化
  3. indicator: ユーザーに処理中であることを明示

以下の図は、組み合わせパターンでの防御層を示しています。

mermaidflowchart TD
  click["ユーザークリック"] --> throttle{"throttle<br/>1秒以内?"}
  throttle -->|Yes| block1["ブロック<br/>(第1層防御)"]
  throttle -->|No| disabled{"ボタン<br/>disabled?"}
  disabled -->|Yes| block2["ブロック<br/>(第2層防御)"]
  disabled -->|No| request["リクエスト送信"]
  request --> disableBtn["ボタン無効化<br/>インジケーター表示"]
  disableBtn --> wait["レスポンス待機"]
  wait --> response["レスポンス受信"]
  response --> enable["ボタン有効化<br/>インジケーター非表示"]
  enable --> idle["待機状態"]

図で理解できる要点:

  • 第 1 層(throttle)で短時間の連打を防御
  • 第 2 層(disabled)でリクエスト中の操作を防御
  • インジケーターでユーザーに状態を明示し、誤操作を防止

具体例

例 1: EC サイトの購入ボタン

EC サイトでは、購入ボタンの二重送信は致命的です。以下は、購入処理に特化した実装例です。

HTML 実装

html<!-- 購入ボタンの実装 -->
<form id="purchaseForm" hx-post="/api/purchase">
  <div class="product-info">
    <h3>商品: プレミアムプラン</h3>
    <p>価格: ¥9,800</p>
  </div>

  <input
    type="hidden"
    name="product_id"
    value="premium-001"
  />
  <input type="hidden" name="quantity" value="1" />
</form>
html  <button
    type="submit"
    hx-trigger="click once"
    hx-disabled-elt="this"
    hx-indicator="#purchase-spinner"
    hx-target="#purchase-result">
    <span class="button-text">購入する</span>
    <span id="purchase-spinner" class="htmx-indicator">
      <span class="spinner"></span>
      処理中...
    </span>
  </button>
</form>

<div id="purchase-result"></div>

ここでは hx-trigger="click once" により、ボタンは 1 回しかクリックできません。さらに hx-disabled-elt="this" で、リクエスト中は物理的に無効化されます。

CSS 実装

css/* ボタンの基本スタイル */
button[type='submit'] {
  background-color: #007bff;
  color: white;
  padding: 12px 24px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s ease;
}
css/* リクエスト中のスタイル */
button[type='submit']:disabled {
  background-color: #6c757d;
  cursor: not-allowed;
  opacity: 0.6;
}

/* インジケーターの表示制御 */
.htmx-indicator {
  display: none;
}

.htmx-request .htmx-indicator {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}

.htmx-request .button-text {
  display: none;
}
css/* スピナーのアニメーション */
.spinner {
  display: inline-block;
  width: 16px;
  height: 16px;
  border: 2px solid rgba(255, 255, 255, 0.3);
  border-top-color: white;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

サーバー側の処理(Node.js + Express)

javascript// 購入 API のエンドポイント
import express from 'express';
import { body, validationResult } from 'express-validator';

const app = express();
javascript// 購入処理のハンドラー
app.post('/api/purchase',
  // バリデーション
  body('product_id').notEmpty().withMessage('商品IDは必須です'),
  body('quantity').isInt({ min: 1 }).withMessage('数量は1以上の整数です'),

  async (req, res) => {
    // バリデーション結果の確認
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).send(`
        <div class="error">
          <p>エラーが発生しました</p>
          <ul>
            ${errors.array().map(e => `<li>${e.msg}</li>`).join('')}
          </ul>
        </div>
      `);
    }
javascript    try {
      // データベースへの登録処理
      const { product_id, quantity } = req.body;
      const order = await db.createOrder({
        productId: product_id,
        quantity: parseInt(quantity),
        userId: req.session.userId,
        createdAt: new Date()
      });
javascript      // 成功レスポンスを返す
      res.send(`
        <div class="success">
          <h4>購入が完了しました</h4>
          <p>注文番号: ${order.id}</p>
          <p>商品: ${order.productName}</p>
          <p>金額: ¥${order.amount.toLocaleString()}</p>
        </div>
      `);
    } catch (error) {
      console.error('Purchase error:', error);
      res.status(500).send(`
        <div class="error">
          <p>購入処理中にエラーが発生しました</p>
          <p>しばらく時間をおいて再度お試しください</p>
        </div>
      `);
    }
  }
);

例 2: 検索フォームのオートコンプリート

検索フォームでは、ユーザーの入力に応じてリアルタイムに候補を表示する必要があります。この場合、debounce が有効です。

HTML 実装

html<!-- オートコンプリート検索フォーム -->
<div class="search-container">
  <label for="search">商品検索</label>
  <input
    type="text"
    id="search"
    name="q"
    placeholder="商品名を入力してください"
    hx-get="/api/search"
    hx-trigger="keyup changed debounce:300ms"
    hx-target="#search-results"
    hx-indicator="#search-spinner"
    autocomplete="off"
  />
</div>
html  <div id="search-spinner" class="htmx-indicator">
    <span class="spinner-small"></span>
    検索中...
  </div>

  <div id="search-results"></div>
</div>

hx-trigger="keyup changed debounce:300ms" により、以下の制御が実現されています。

  • keyup: キーを離した時に発火
  • changed: 前回と値が変わった時のみ発火(同じ値の連続入力は無視)
  • debounce:300ms: 最後の入力から 300ms 後にリクエスト送信

CSS 実装

css/* 検索コンテナのスタイル */
.search-container {
  position: relative;
  max-width: 500px;
  margin: 20px 0;
}

#search {
  width: 100%;
  padding: 10px 40px 10px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 16px;
}
css/* 検索中のインジケーター */
#search-spinner {
  display: none;
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  font-size: 12px;
  color: #666;
}

#search-spinner.htmx-indicator {
  display: flex;
  align-items: center;
  gap: 6px;
}
css/* 検索結果の表示 */
#search-results {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  background: white;
  border: 1px solid #ddd;
  border-top: none;
  border-radius: 0 0 4px 4px;
  max-height: 400px;
  overflow-y: auto;
  z-index: 1000;
}

#search-results:empty {
  display: none;
}

サーバー側の処理

javascript// 検索 API のエンドポイント
app.get('/api/search', async (req, res) => {
  const query = req.query.q;

  // 空文字列の場合は空の結果を返す
  if (!query || query.trim().length === 0) {
    return res.send('');
  }
javascript  try {
    // データベースで検索
    const results = await db.searchProducts({
      keyword: query,
      limit: 10
    });
javascript// 結果が空の場合
if (results.length === 0) {
  return res.send(`
        <div class="no-results">
          <p>「${query}」に一致する商品が見つかりませんでした</p>
        </div>
      `);
}
javascript    // 検索結果を HTML として返す
    const html = results.map(product => `
      <div class="search-result-item">
        <img src="${product.thumbnail}" alt="${product.name}" />
        <div class="item-info">
          <h4>${product.name}</h4>
          <p class="price">¥${product.price.toLocaleString()}</p>
        </div>
      </div>
    `).join('');

    res.send(html);
  } catch (error) {
    console.error('Search error:', error);
    res.status(500).send(`
      <div class="error">検索中にエラーが発生しました</div>
    `);
  }
});

例 3: いいねボタンの連打防止

SNS のようないいねボタンでは、throttle を使って短時間の連打を防ぎつつ、ユーザーに即座にフィードバックを提供します。

HTML 実装

html<!-- いいねボタン -->
<div class="like-container">
  <button
    class="like-button"
    hx-post="/api/posts/123/like"
    hx-trigger="click throttle:1s"
    hx-swap="outerHTML"
    hx-disabled-elt="this"
  >
    <svg class="heart-icon" viewBox="0 0 24 24">
      <path
        d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
      />
    </svg>
    <span class="like-count">128</span>
  </button>
</div>

hx-swap="outerHTML" により、サーバーから返された HTML でボタン全体が置き換わります。これにより、いいね数の更新とボタンの状態変更が同時に反映されます。

CSS 実装

css/* いいねボタンの基本スタイル */
.like-button {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 16px;
  background: transparent;
  border: 1px solid #ddd;
  border-radius: 20px;
  cursor: pointer;
  transition: all 0.2s ease;
}

.like-button:hover {
  background-color: #ffe6e6;
  border-color: #ff4444;
}
css/* ハートアイコンのスタイル */
.heart-icon {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: #666;
  stroke-width: 2;
  transition: all 0.2s ease;
}

/* いいね済みの状態 */
.like-button.liked .heart-icon {
  fill: #ff4444;
  stroke: #ff4444;
}

.like-button.liked {
  border-color: #ff4444;
  background-color: #ffe6e6;
}
css/* リクエスト中のスタイル */
.like-button:disabled {
  opacity: 0.6;
  cursor: wait;
}

.like-count {
  font-weight: 600;
  color: #333;
}

サーバー側の処理

javascript// いいね API のエンドポイント
app.post('/api/posts/:postId/like', async (req, res) => {
  const { postId } = req.params;
  const userId = req.session.userId;

  if (!userId) {
    return res.status(401).send(`
      <div class="error">ログインが必要です</div>
    `);
  }
javascript  try {
    // いいね状態をトグル
    const isLiked = await db.isPostLikedByUser(postId, userId);

    if (isLiked) {
      // いいね解除
      await db.unlikePost(postId, userId);
    } else {
      // いいね追加
      await db.likePost(postId, userId);
    }
javascript// 更新後のいいね数を取得
const likeCount = await db.getPostLikeCount(postId);
const newIsLiked = !isLiked;
javascript    // 更新されたボタンを返す
    res.send(`
      <button
        class="like-button ${newIsLiked ? 'liked' : ''}"
        hx-post="/api/posts/${postId}/like"
        hx-trigger="click throttle:1s"
        hx-swap="outerHTML"
        hx-disabled-elt="this">
        <svg class="heart-icon" viewBox="0 0 24 24">
          <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
        </svg>
        <span class="like-count">${likeCount}</span>
      </button>
    `);
  } catch (error) {
    console.error('Like error:', error);
    res.status(500).send(`
      <div class="error">エラーが発生しました</div>
    `);
  }
});

パターン比較表

実装パターンごとの特徴と適用シーンを表にまとめました。

#パターン主な用途メリットデメリット適用例
1once1 回限りの実行完全に二重送信を防げる再実行できない会員登録、決済
2throttle連打制限一定間隔で実行可能即座に反応しない場合があるいいねボタン、投票
3debounce入力完了待機不要なリクエストを削減反応が遅れる検索、オートコンプリート
4disabledボタン無効化視覚的に明確JavaScript 無効時は機能しないフォーム送信
5組み合わせ多層防御最も堅牢実装が複雑重要な操作全般

まとめ

htmx における二重送信問題は、フレームワークのデフォルト挙動を理解し、適切な属性を使うことで確実に防ぐことができます。

重要なポイント

htmx のデフォルト防止機能

htmx は同一要素からのリクエストを自動的にブロックしますが、これに依存せず明示的な制御を行うことが推奨されます。

trigger パターンの使い分け

  • once: 絶対に 1 回しか実行してはいけない操作(決済、登録)
  • throttle: 短時間の連打を防ぎたい操作(いいね、投票)
  • debounce: 入力完了を待ちたい操作(検索、フィルター)

disable パターンの活用

hx-disabled-elt でボタンを物理的に無効化し、CSS でローディング状態を表現することで、ユーザーに明確なフィードバックを提供できます。

多層防御の実践

実務では trigger + disable + インジケーターの組み合わせにより、技術的な防御とユーザー体験の両立を実現しましょう。

実装チェックリスト

以下のチェックリストを使って、実装の抜け漏れを確認してください。

  • リクエストの発火条件を hx-trigger で明示的に定義した
  • 重要な操作には once または throttle を設定した
  • リクエスト中は hx-disabled-elt でボタンを無効化した
  • ユーザーにローディング状態を視覚的に示した(hx-indicator、CSS)
  • サーバー側でもバリデーションとエラーハンドリングを実装した
  • 実際にブラウザで連打テストを実施し、二重送信が起きないことを確認した

htmx の持つシンプルさと強力さを活かしながら、適切な二重送信対策を実装することで、ユーザーにとって安全で快適な Web アプリケーションを構築できます。本記事で紹介したパターンを、ぜひ実際のプロジェクトで活用してみてください。

関連リンク