htmx 逆引きレシピ
破壊的操作の前に確認を出すには?
削除や取消、一括更新などの “破壊的操作” は、確認なしで実行できてしまうと事故につながりやすいです。
そこで htmx の hx-confirm を使い、実行前に必ず確認ダイアログを挟むようにします。
このページでは、行単位の操作・チェックしたものの一括更新・本番データを触る操作の3パターンをまとめて紹介します。
クライアント側の確認だけでなく、サーバ側でも条件チェックを入れるのが安全です。
使用するhtmx属性
hx-confirm:操作の実行前に確認ダイアログを出し、誤操作を防ぐhx-delete:DELETEで削除などの破壊的操作を呼び出す(行削除向き)hx-post:POSTで取消/差し戻し/一括更新などの更新処理を呼び出すhx-target:返ってきたHTMLを差し替える先の要素(結果エリアやtbodyなど)を指定hx-swap:差し替え方を指定(例:innerHTML)hx-include:フォーム外の要素も含めて送信し、一括更新の対象IDなどをまとめて送るhx-swap-oob:結果メッセージとテーブル再描画を“同時更新”する(OOB差し替え)
利用シーン
- 「削除/取消/差し戻し」:誤操作が起きやすい行操作に確認を挟みたい
- 「一括更新」:チェックした複数行をまとめて更新する前に確認して事故を防ぎたい
- 「本番データを触る操作」:取り消しできない処理に強い確認+条件チェックを入れたい
デモ用データ作成
PHP
<?php
// デモ用データが未作成なら初期化する
if (!isset($_SESSION['demo_confirm_rows']) || !is_array($_SESSION['demo_confirm_rows'])) {
// 行操作デモ用のデータを作る
$_SESSION['demo_confirm_rows'] = [
['id' => 601, 'title' => '申請:アカウント追加', 'owner' => 'sato', 'status' => 'OPEN'],
['id' => 602, 'title' => '申請:権限変更', 'owner' => 'tanaka', 'status' => 'PENDING'],
['id' => 603, 'title' => '申請:端末貸与', 'owner' => 'suzuki', 'status' => 'OPEN'],
['id' => 604, 'title' => '申請:メーリングリスト', 'owner' => 'tanaka', 'status' => 'DONE'],
];
}
// 一括更新デモ用データが未作成なら初期化する
if (!isset($_SESSION['demo_confirm_bulk']) || !is_array($_SESSION['demo_confirm_bulk'])) {
// 一括更新デモ用のデータを作る
$_SESSION['demo_confirm_bulk'] = [
['id' => 7101, 'title' => '申請:端末購入', 'owner' => 'tanaka', 'status' => 'OPEN'],
['id' => 7102, 'title' => '申請:備品補充', 'owner' => 'sato', 'status' => 'OPEN'],
['id' => 7103, 'title' => '申請:権限棚卸し', 'owner' => 'suzuki', 'status' => 'PENDING'],
['id' => 7104, 'title' => '申請:VPN追加', 'owner' => 'tanaka', 'status' => 'DONE'],
];
}
// 本番操作デモ用データが未作成なら初期化する
if (!isset($_SESSION['demo_confirm_prod']) || !is_array($_SESSION['demo_confirm_prod'])) {
// 本番操作デモ用のデータを作る
$_SESSION['demo_confirm_prod'] = [
'version' => 'v2026.01.26',
'deploy_target' => 'prod',
'last_deploy' => '未実行',
];
}
// 行操作デモのデータを取り出す
$rows = (array)$_SESSION['demo_confirm_rows'];
// 一括更新デモのデータを取り出す
$bulk = (array)$_SESSION['demo_confirm_bulk'];
// 本番操作デモのデータを取り出す
$prod = (array)$_SESSION['demo_confirm_prod'];
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
?>
① 削除/取消/差し戻し(行操作)
1行に対して、削除(DELETE)・取消(POST)・差し戻し(POST)を実行します。
各ボタンに hx-confirm を付けて、押し間違いを防ぎます。
HTML
<div class="DEMO">
<h4>① 削除/取消/差し戻し(行操作)</h4>
<div class="TABLE_WRAPPER">
<table>
<thead>
<tr>
<th class="W15pc">ID</th>
<th>タイトル</th>
<th class="W20pc">担当</th>
<th class="W20pc">ステータス</th>
<th class="W20pc">操作</th>
</tr>
</thead>
<tbody id="DEMO_CONFIRM_1_TBODY">
<?php foreach ($rows as $r): ?>
<tr>
<td><?= h((string)$r['id']) ?></td>
<td><?= h((string)$r['title']) ?></td>
<td><?= h((string)$r['owner']) ?></td>
<td>
<?php
// 表示用クラスを決める
$cls = 'open';
// ステータスに応じて色を変える
if ($r['status'] === 'PENDING') $cls = 'pending';
// ステータスに応じて色を変える
if ($r['status'] === 'DONE') $cls = 'done';
?>
<span class="TAG <?= h($cls) ?>"><?= h((string)$r['status']) ?></span>
</td>
<td>
<div class="BTN-GROUP">
<!-- 削除(DELETE) -->
<button
type="button"
class="BTN is-ng"
hx-delete="/htmx/demo/_confirm_row_action.php?action=delete&id=<?= h((string)$r['id']) ?>"
hx-target="#DEMO_CONFIRM_1_TBODY"
hx-swap="innerHTML"
hx-confirm="この行を削除します。\n取り消しできません。\n実行しますか?"
>
削除
</button>
<!-- 取消(POST) -->
<button
type="button"
class="BTN is-warn"
hx-post="/htmx/demo/_confirm_row_action.php?action=cancel&id=<?= h((string)$r['id']) ?>"
hx-target="#DEMO_CONFIRM_1_TBODY"
hx-swap="innerHTML"
hx-confirm="この申請を取消します。\n実行しますか?"
>
取消
</button>
<!-- 差し戻し(POST) -->
<button
type="button"
class="BTN is-ghost"
hx-post="/htmx/demo/_confirm_row_action.php?action=remand&id=<?= h((string)$r['id']) ?>"
hx-target="#DEMO_CONFIRM_1_TBODY"
hx-swap="innerHTML"
hx-confirm="この申請を差し戻します。\n実行しますか?"
>
差し戻し
</button>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="HTMX-NOTE" style="margin-top:.75rem;">
※ デモなので、操作後はテーブル全体を再描画しています(実務でも安全で分かりやすいです)。
</p>
</div>
PHP
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// データ配列を取り出す
$rows = (array)($_SESSION['demo_confirm_rows'] ?? []);
// 操作種別を受け取る
$action = (string)($_GET['action'] ?? '');
// idを受け取る
$id = (int)($_GET['id'] ?? 0);
// 対象がない場合はそのまま返す
if ($id <= 0 || $action === '') {
// 現状を描画して終了する
foreach ($rows as $r) {
// 表示用クラスを決める
$cls = 'open';
// ステータスに応じて色を変える
if (($r['status'] ?? '') === 'PENDING') $cls = 'pending';
// ステータスに応じて色を変える
if (($r['status'] ?? '') === 'DONE') $cls = 'done';
// 行を描画する
echo '<tr>';
// ID
echo '<td>' . h((string)($r['id'] ?? '')) . '</td>';
// タイトル
echo '<td>' . h((string)($r['title'] ?? '')) . '</td>';
// 担当
echo '<td>' . h((string)($r['owner'] ?? '')) . '</td>';
// ステータス
echo '<td><span class="TAG ' . h($cls) . '">' . h((string)($r['status'] ?? '')) . '</span></td>';
// 操作列(ボタンはデモページ側で付けているのでここでは空でもOK)
echo '<td><span class="HTMX-NOTE">更新してください</span></td>';
// 閉じる
echo '</tr>';
}
// 終了する
exit;
}
// 行を走査して更新する
$newRows = [];
// 1行ずつ処理する
foreach ($rows as $r) {
// 現在行のIDを取り出す
$rid = (int)($r['id'] ?? 0);
// 対象行以外はそのまま保持する
if ($rid !== $id) {
// そのまま追加する
$newRows[] = $r;
// 次へ
continue;
}
// 削除なら配列に入れずにスキップする
if ($action === 'delete') {
// 次へ(追加しない)
continue;
}
// 取消ならステータスをCANCELにする
if ($action === 'cancel') {
// ステータスを更新する
$r['status'] = 'CANCEL';
}
// 差し戻しならステータスをREMANDにする
if ($action === 'remand') {
// ステータスを更新する
$r['status'] = 'REMAND';
}
// 更新した行を追加する
$newRows[] = $r;
}
// セッションに保存する
$_SESSION['demo_confirm_rows'] = $newRows;
// 更新後の行を描画する
foreach ($newRows as $r) {
// 表示用クラスを決める
$cls = 'open';
// ステータスに応じて色を変える
if (($r['status'] ?? '') === 'PENDING') $cls = 'pending';
// ステータスに応じて色を変える
if (($r['status'] ?? '') === 'DONE') $cls = 'done';
// CANCEL/REMANDも色を変える
if (($r['status'] ?? '') === 'CANCEL') $cls = 'pending';
// CANCEL/REMANDも色を変える
if (($r['status'] ?? '') === 'REMAND') $cls = 'pending';
// 行を描画する
echo '<tr>';
// ID
echo '<td>' . h((string)($r['id'] ?? '')) . '</td>';
// タイトル
echo '<td>' . h((string)($r['title'] ?? '')) . '</td>';
// 担当
echo '<td>' . h((string)($r['owner'] ?? '')) . '</td>';
// ステータス
echo '<td><span class="TAG ' . h($cls) . '">' . h((string)($r['status'] ?? '')) . '</span></td>';
// 操作列(デモページ側のボタンをそのまま使いたいので、ここでも同じボタンを再描画する)
echo '<td>';
echo '<div class="BTN-GROUP">';
// 削除
echo '<button type="button" class="BTN is-ng"';
echo ' hx-delete="/htmx/demo/_confirm_row_action.php?action=delete&id=' . h((string)($r['id'] ?? '')) . '"';
echo ' hx-target="#DEMO_CONFIRM_1_TBODY" hx-swap="innerHTML"';
echo ' hx-confirm="この行を削除します。\n取り消しできません。\n実行しますか?"';
echo '>削除</button>';
// 取消
echo '<button type="button" class="BTN is-warn"';
echo ' hx-post="/htmx/demo/_confirm_row_action.php?action=cancel&id=' . h((string)($r['id'] ?? '')) . '"';
echo ' hx-target="#DEMO_CONFIRM_1_TBODY" hx-swap="innerHTML"';
echo ' hx-confirm="この申請を取消します。\n実行しますか?"';
echo '>取消</button>';
// 差し戻し
echo '<button type="button" class="BTN is-ghost"';
echo ' hx-post="/htmx/demo/_confirm_row_action.php?action=remand&id=' . h((string)($r['id'] ?? '')) . '"';
echo ' hx-target="#DEMO_CONFIRM_1_TBODY" hx-swap="innerHTML"';
echo ' hx-confirm="この申請を差し戻します。\n実行しますか?"';
echo '>差し戻し</button>';
// 閉じる
echo '</div>';
echo '</td>';
// 閉じる
echo '</tr>';
}
デモ
① 削除/取消/差し戻し(行操作)
| ID | タイトル | 担当 | ステータス | 操作 |
|---|---|---|---|---|
| 601 | 申請:アカウント追加 | sato | OPEN |
|
| 602 | 申請:権限変更 | tanaka | PENDING |
|
| 603 | 申請:端末貸与 | suzuki | OPEN |
|
| 604 | 申請:メーリングリスト | tanaka | DONE |
|
※ デモなので、操作後はテーブル全体を再描画しています(実務でも安全で分かりやすいです)。
解説
-
ボタンごとに
hx-confirmを設定し、操作内容に応じて確認文を変えています。
特に「削除」のように戻せない操作は、警告を強めてユーザーに意識させるのが安全です。 -
実行後は
hx-targetで<tbody>を丸ごと再描画し、最新状態を確実に反映しています。
行単位で差し替えるよりも「一覧を再構築する」方が、整合性が崩れにくく安心して運用できます。
※confirmで¥nが改行にならない場合、普通に改行することでも解決します。
② 一括更新(チェックしてまとめて更新)
複数行をまとめて更新する操作は、1クリックで影響範囲が大きくなります。
そのため hx-confirm を必須にし、実行前に確認を挟みます。
HTML
<div class="DEMO">
<h4>② 一括更新(チェックしてまとめて更新)</h4>
<form id="DEMO_CONFIRM_2_FORM" class="FORM">
<label>
一括で変更するステータス
<select name="new_status">
<option value="OPEN">OPEN</option>
<option value="PENDING">PENDING</option>
<option value="DONE">DONE</option>
</select>
</label>
<button
type="button"
class="BTN is-ok"
hx-post="/htmx/demo/_confirm_bulk_update.php"
hx-target="#DEMO_CONFIRM_2_RESULT"
hx-swap="innerHTML"
hx-include="#DEMO_CONFIRM_2_FORM"
hx-confirm="チェックした行を一括更新します。\n本当に実行しますか?"
>
一括更新する
</button>
</form>
<div id="DEMO_CONFIRM_2_RESULT" class="MSG ok" style="margin-top:.75rem;">
<p class="HTMX-NOTE">ここに一括更新結果が表示されます。</p>
</div>
<div class="HR"></div>
<div class="TABLE_WRAPPER">
<table>
<thead>
<tr>
<th class="W50">選択</th>
<th class="W75">ID</th>
<th>タイトル</th>
<th class="W120">担当</th>
<th class="W120">ステータス</th>
</tr>
</thead>
<tbody id="DEMO_CONFIRM_2_TBODY">
<?php foreach ($bulk as $b): ?>
<tr>
<td class="TAC">
<input type="checkbox" name="ids[]" form="DEMO_CONFIRM_2_FORM" value="<?= h((string)$b['id']) ?>">
</td>
<td><?= h((string)$b['id']) ?></td>
<td><?= h((string)$b['title']) ?></td>
<td><?= h((string)$b['owner']) ?></td>
<td><?= h((string)$b['status']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
PHP
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// データ配列を取り出す
$bulk = (array)($_SESSION['demo_confirm_bulk'] ?? []);
// 変更後ステータスを受け取る
$newStatus = (string)($_POST['new_status'] ?? '');
// 対象ID配列を受け取る
$ids = (array)($_POST['ids'] ?? []);
// 変更後ステータスが空なら補正する
if ($newStatus === '') $newStatus = 'OPEN';
// 対象IDが空ならエラーを返す
if (count($ids) === 0) {
// エラーメッセージを返す
echo '<div class="MSG ng"><strong>一括更新できません。</strong><div class="HTMX-NOTE">対象をチェックしてください。</div></div>';
// (OOB)テーブルはそのまま再描画(現状維持)
echo '<table><tbody hx-swap-oob="innerHTML:#DEMO_CONFIRM_2_TBODY">';
foreach ($bulk as $b) {
echo '<tr>';
echo '<td class="TAC"><input type="checkbox" name="ids[]" form="DEMO_CONFIRM_2_FORM" value="' . h((string)($b['id'] ?? '')) . '"></td>';
echo '<td>' . h((string)($b['id'] ?? '')) . '</td>';
echo '<td>' . h((string)($b['title'] ?? '')) . '</td>';
echo '<td>' . h((string)($b['owner'] ?? '')) . '</td>';
echo '<td>' . h((string)($b['status'] ?? '')) . '</td>';
echo '</tr>';
}
echo '</tbody></table>';
// 終了する
exit;
}
// 対象IDをintに揃える
$targetIds = [];
// 1つずつ変換する
foreach ($ids as $v) {
// 数字に変換して追加する
$targetIds[] = (int)$v;
}
// 更新した件数を数える
$updated = 0;
// 更新後の配列を作る
$newBulk = [];
// 1行ずつ処理する
foreach ($bulk as $b) {
// IDを取り出す
$bid = (int)($b['id'] ?? 0);
// 対象ならステータスを更新する
if (in_array($bid, $targetIds, true)) {
// ステータスを更新する
$b['status'] = $newStatus;
// 件数を増やす
$updated++;
}
// 配列に追加する
$newBulk[] = $b;
}
// セッションに保存する
$_SESSION['demo_confirm_bulk'] = $newBulk;
// 通信っぽさを出す(少し待つ)
usleep(400000);
// =========================
// ① hx-target向け:結果メッセージ(通常レスポンス)
// =========================
echo '<div class="MSG ok">';
echo '<strong>一括更新しました。</strong>';
echo '<div>更新件数:' . h((string)$updated) . '件</div>';
echo '<div>変更後ステータス:' . h($newStatus) . '</div>';
echo '</div>';
// =========================
// ② OOB:テーブルtbodyを再描画(innerHTML差し替え)
// =========================
echo '<table><tbody hx-swap-oob="innerHTML:#DEMO_CONFIRM_2_TBODY">';
foreach ($newBulk as $b) {
// 行を描画する
echo '<tr>';
// チェックボックス(form属性でフォームに紐付け)
echo '<td class="TAC"><input type="checkbox" name="ids[]" form="DEMO_CONFIRM_2_FORM" value="' . h((string)($b['id'] ?? '')) . '"></td>';
// ID
echo '<td>' . h((string)($b['id'] ?? '')) . '</td>';
// タイトル
echo '<td>' . h((string)($b['title'] ?? '')) . '</td>';
// 担当
echo '<td>' . h((string)($b['owner'] ?? '')) . '</td>';
// ステータス
echo '<td>' . h((string)($b['status'] ?? '')) . '</td>';
// 行を閉じる
echo '</tr>';
}
echo '</tbody></table>';
デモ
② 一括更新(チェックしてまとめて更新)
ここに一括更新結果が表示されます。
| 選択 | ID | タイトル | 担当 | ステータス |
|---|---|---|---|---|
| 7101 | 申請:端末購入 | tanaka | OPEN | |
| 7102 | 申請:備品補充 | sato | OPEN | |
| 7103 | 申請:権限棚卸し | suzuki | PENDING | |
| 7104 | 申請:VPN追加 | tanaka | DONE |
解説
-
hx-includeでフォーム全体を送信し、チェックしたids[]とnew_statusをまとめてサーバへ渡します。
「どれを更新するのか」をユーザーが明確に選べるため、意図しない更新を減らせます。 -
保存結果(メッセージ)だけでなく、
hx-swap-oobを使ってテーブルの<tbody>も同時に再描画しています。
これにより「更新したのに一覧が古いまま」という違和感がなくなり、操作後の状態が分かりやすくなります。 -
チェックが0件の場合はサーバ側で更新を拒否し、エラー表示を返すようにしています。
クライアント側だけに頼らず、サーバ側でも条件を検証することで事故をさらに防げます。
※confirmで¥nが改行にならない場合、普通に改行することでも解決します。
③ 本番データを触る操作(強い確認+理解チェック)
本番反映のような “戻せない操作” は、確認だけでなく 理解したチェックを入れるとさらに安全です。
クライアント側で確認しつつ、サーバ側でもチェックの有無を検証して二重ロックにします。
HTML
<div class="DEMO">
<h4>③ 本番データを触る操作(強い確認+理解チェック)</h4>
<form id="DEMO_CONFIRM_3_FORM" class="FORM">
<div class="CARD" style="background:rgba(0,0,0,.02);">
<div><strong>反映対象</strong>:<?= h((string)$prod['deploy_target']) ?></div>
<div><strong>バージョン</strong>:<?= h((string)$prod['version']) ?></div>
<div><strong>最終反映</strong>:<?= h((string)$prod['last_deploy']) ?></div>
</div>
<label class="W100pc">
<input type="checkbox" name="ack" value="1">
<strong>本番データを変更する操作である</strong>ことを理解しました(取り消しできない場合があります)
</label>
<button
type="button"
class="BTN is-ng"
hx-post="/htmx/demo/_confirm_prod_action.php"
hx-target="#DEMO_CONFIRM_3_RESULT"
hx-swap="innerHTML"
hx-include="#DEMO_CONFIRM_3_FORM"
hx-confirm="⚠ 本番反映を実行します。\n取り消しできません。\n本当に実行しますか?"
>
本番に反映する
</button>
</form>
<div id="DEMO_CONFIRM_3_RESULT" class="MSG ok" style="margin-top:.75rem;">
<p class="HTMX-NOTE">ここに実行結果が表示されます。</p>
</div>
</div>
PHP
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// “理解した”チェックを受け取る
$ack = (string)($_POST['ack'] ?? '');
// チェックがなければ拒否する
if ($ack !== '1') {
// エラーメッセージを返す
echo '<div class="MSG ng">';
echo '<strong>実行できません。</strong>';
echo '<div class="HTMX-NOTE">「理解しました」にチェックしてから実行してください。</div>';
echo '</div>';
// 終了する
exit;
}
// 本番データを取り出す
$prod = (array)($_SESSION['demo_confirm_prod'] ?? []);
// 現在時刻を作る
$now = date('Y-m-d H:i:s');
// 最終反映時刻を更新する
$prod['last_deploy'] = $now;
// セッションに保存する
$_SESSION['demo_confirm_prod'] = $prod;
// 通信っぽさを出す(少し待つ)
usleep(700000);
// 成功メッセージを返す
echo '<div class="MSG ok">';
echo '<strong>本番反映を実行しました。</strong>';
echo '<div>反映先:' . h((string)($prod['deploy_target'] ?? 'prod')) . '</div>';
echo '<div>バージョン:' . h((string)($prod['version'] ?? 'unknown')) . '</div>';
echo '<div>実行時刻:' . h($now) . '</div>';
echo '<div class="HTMX-NOTE">※デモなので実際の反映は行っていません。</div>';
echo '</div>';
デモ
③ 本番データを触る操作(強い確認+理解チェック)
ここに実行結果が表示されます。
解説
-
hx-confirmで強い警告文を表示し、「本当に実行してよい操作なのか」を再確認させます。
誤クリックが起きやすい場面では、確認の“圧”を上げるのが効果的です。 -
フォームの
ack(理解チェック)が付いていない場合は、サーバ側で実行を拒否する作りにしています。
確認ダイアログをOKしただけでは通らないため、より確実に“意思確認”ができます。 -
「確認(クライアント)」+「検証(サーバ)」の二段構えにすることで、運用上の事故を最小化できます。
特に本番系の操作は、フロントだけで完結させずサーバ側のガードを必ず入れるのがおすすめです。
次に読むオススメレシピ
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール