htmx 逆引きレシピ
保存の二重送信を防ぐには?
公開日:
最終更新日:
保存ボタンの連打や通信遅延があると、同じデータが二重に登録される事故が起きがちです。
htmxなら、送信中の無効化・ローディング表示・リクエスト競合の制御までを、属性だけでまとめて実装できます。
このページでは「保存ボタン連打を防止」「通信中はフォームを無効化」「競合を避けたい」を1つのデモに統合し、hx-disabled-elt / hx-indicator / hx-sync / hx-trigger を使った“安全な保存”の定番パターンを解説します。
使用するhtmx属性
hx-trigger:リクエストを発火するタイミングを指定(このデモはsubmitで送信)hx-indicator:通信中表示(保存中...)を出す要素を指定し、ユーザーに待ち状態を伝えるhx-disabled-elt:通信中だけボタンやフォーム要素をdisabledにして、連打や編集を防止するhx-sync:同じ要素からのリクエストが重なった時の挙動を制御し、競合を回避する(例:this:drop)
利用シーン
- 「保存ボタン連打を防止」:通信が遅い環境でも二重登録を起こしたくない
- 「通信中はフォームを無効化」:送信中に入力が変わる/同時編集される事故を避けたい
- 「競合を避けたい」:同じフォームから複数リクエストが飛んだ時に、後続を落とす/置き換えるなど制御したい
保存の二重送信を防ぐには?
保存ボタンの連打や通信遅延による二重送信を、htmxの属性だけで防ぎます。
通信中はフォームを無効化し、さらにリクエスト競合(同時送信)も抑止します。
HTML
<div class="DEMO">
<h4>保存の二重送信を防ぐには?</h4>
<form
id="DEMO_DOUBLE_SUBMIT_FORM"
class="FORM"
method="post"
hx-post="/htmx/demo/_double_submit_save.php"
hx-trigger="submit"
hx-target="#DEMO_DOUBLE_SUBMIT_RESULT"
hx-swap="innerHTML"
hx-indicator="#LOADER_MODAL"
hx-disabled-elt="#DEMO_DOUBLE_SUBMIT_SAVE, #DEMO_DOUBLE_SUBMIT_FORM input, #DEMO_DOUBLE_SUBMIT_FORM select, #DEMO_DOUBLE_SUBMIT_FORM textarea"
hx-sync="this:drop"
>
<label>
タイトル
<input type="text" name="title" maxlength="64" value="申請:端末貸与">
</label>
<label>
担当
<select name="owner">
<option value="tanaka">tanaka</option>
<option value="sato">sato</option>
<option value="suzuki" selected>suzuki</option>
</select>
</label>
<label>
メモ
<textarea name="memo" rows="3" maxlength="256">急ぎです。</textarea>
</label>
<button id="DEMO_DOUBLE_SUBMIT_SAVE" type="submit" class="BTN is-ok">
保存
</button>
<div id="LOADER_MODAL" aria-hidden="true">
<div id="LOADER" role="status" aria-live="polite" aria-label="通信中"></div>
</div>
</form>
<div id="DEMO_DOUBLE_SUBMIT_RESULT" class="FORM-RESULT">
<span class="HTMX-NOTE">「保存」を連打しても、通信中は無効化され、競合も抑止されます。</span>
</div>
</div>
PHP
<?php
// 型を厳密に扱う
declare(strict_types=1);
// 返すのはHTML(UTF-8)
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// 体感できるように少し遅延させる(デモ用)
usleep(900000);
// リクエストメソッドを取得する
$method = (string)($_SERVER['REQUEST_METHOD'] ?? '');
// POST以外は弾く
if ($method !== 'POST') {
// エラー表示を返す
echo '<div class="FORM-RESULT is-ng"><strong>NG</strong>:POSTで送ってください</div>';
// 終了する
exit;
}
// titleを受け取る
$title = (string)($_POST['title'] ?? '');
// ownerを受け取る
$owner = (string)($_POST['owner'] ?? '');
// memoを受け取る
$memo = (string)($_POST['memo'] ?? '');
// 前後空白を除去する
$title = trim($title);
// 前後空白を除去する
$owner = trim($owner);
// 前後空白を除去する
$memo = trim($memo);
// titleが空なら補う(デモ用)
if ($title === '') $title = '(未入力)';
// ownerが空なら補う(デモ用)
if ($owner === '') $owner = '(未選択)';
// 保存時刻(デモ用)
$now = date('Y-m-d H:i:s');
// 成功結果を返す
echo '<div class="FORM-RESULT is-ok">';
// 見出しを返す
echo '<strong>保存しました。</strong>';
// 内容を返す
echo '<div>タイトル:' . h($title) . '</div>';
// 内容を返す
echo '<div>担当:' . h($owner) . '</div>';
// 内容を返す
echo '<div>メモ:' . h($memo) . '</div>';
// 保存時刻を返す
echo '<div class="HTMX-NOTE">保存時刻:' . h($now) . '</div>';
// 閉じる
echo '</div>';
CSS
#LOADER_MODAL{
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 9999;
}
#LOADER_MODAL.htmx-request{
display: block;
}
#LOADER{
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
box-shadow: none;
background-color: transparent;
background-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20width%3D%2248%22%20height%3D%2248%22%20fill%3D%22%23ffffff%22%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20begin%3D%22.3s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20begin%3D%22.3s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20begin%3D%22.6s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20begin%3D%22.6s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%3C%2Fsvg%3E');
background-size: 200px 200px;
background-position: center center;
background-repeat: no-repeat;
}
#LOADER::after{
content: '';
height: 250px;
width: 250px;
}
#LOADER_MODAL{
pointer-events: auto;
}
デモ
保存の二重送信を防ぐには?
「保存」を連打しても、通信中は無効化され、競合も抑止されます。
解説
1) 送信中の連打を防ぐ(保存ボタンの無効化)
- フォームに
hx-disabled-eltを指定し、送信中だけ保存ボタンをdisabledにします。 - これにより「保存を連打して二重送信」が起きにくくなります(UI側の第一防衛線)。
2) 送信中はフォーム全体を無効化(入力の変更を封じる)
hx-disabled-eltにフォーム内のinput/select/textareaも含め、送信中の入力変更を防ぎます。- 「送信したつもりの内容」と「画面に残っている内容」がズレる混乱も減らせます。
3) リクエスト競合を抑止(同時送信を制御)
hx-sync="this:drop"を付け、同じフォームから送信が重なった場合は後続リクエストを捨てます。- 通信遅延や連打が発生しても、サーバー側で同時処理が増えにくく、競合や不整合を避けやすくなります。
4) ユーザーに「待ち」を見せる(ローディング表示)
hx-indicatorで「保存中...」を表示し、クリックが効いていることを伝えます。- 待ち状態が見えると、連打そのものが起きにくくなります(体験面の予防)。
注意:UI側の対策(無効化/競合制御)は強力ですが、実務ではサーバー側でも「重複登録防止(トークン/一意制約)」を併用するとさらに安全です。
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール