htmx 逆引きレシピ
行を追加するには?
公開日:
最終更新日:
新規作成したデータを「一覧に反映するために再読み込み…」は、管理画面あるあるの手間です。
htmxなら、作成後に“一覧の先頭へ追加”や“モーダルを閉じつつ更新”を、HTMLだけでスムーズに実装できます。
このページでは「新規作成→一覧の先頭に追加」「モーダル閉じる+一覧更新」「集計も同時更新」の3パターンをデモで整理します。
hx-post / hx-swap / hx-swap-oob を使い、作成→反映の流れを業務UIでそのまま流用できる形にまとめます。
使用するhtmx属性
hx-post:フォーム送信で新規作成し、返ってきたHTMLで一覧を更新するhx-target:返却HTMLを差し替える先を指定(例:tbody/ 結果表示エリア)hx-swap:差し替え方を指定(例:afterbeginで先頭追加 /innerHTML)hx-swap-oob:レスポンス内の一部を「別の場所」へ反映する(モーダル外の一覧/集計を同時更新)
利用シーン
- 「新規作成→一覧に即反映」:作成した内容をすぐ一覧の先頭に追加し、再読み込みを避けたい
- 「モーダル作成→一覧更新」:ポップアップで作成して、閉じたら一覧が更新されていてほしい
- 「一覧+集計も同時更新」:作成と同時に件数カードやステータス集計も更新したい
① 新規作成 → 一覧の先頭に追加
フォーム送信で新規レコードを作成し、返ってきた「1行分のHTML(tr)」を一覧の先頭へ追加します。
hx-target + hx-swap="afterbegin" の最小構成です。
HTML
<div class="DEMO">
<h4>① 新規作成 → 一覧の先頭に追加</h4>
<div class="TABLE_WRAPPER">
<table class="TABLE">
<thead>
<tr>
<th class="W15pc">ID</th>
<th>タイトル</th>
<th class="W20pc">ステータス</th>
</tr>
</thead>
<tbody id="DEMO_CREATE_1_TBODY">
<tr>
<td>501</td>
<td>申請:アカウント追加</td>
<td><span class="BADGE">OPEN</span></td>
</tr>
<tr>
<td>502</td>
<td>申請:権限変更</td>
<td><span class="BADGE">PENDING</span></td>
</tr>
<tr>
<td>503</td>
<td>申請:端末初期化</td>
<td><span class="BADGE">DONE</span></td>
</tr>
</tbody>
</table>
</div>
<form
id="DEMO_CREATE_1_FORM"
class="FORM"
method="post"
hx-post="/htmx/demo/_create_record_1.php"
hx-target="#DEMO_CREATE_1_TBODY"
hx-swap="afterbegin"
>
<label>
タイトル
<input type="text" name="title" value="申請:端末貸与">
</label>
<label>
ステータス
<select name="status">
<option value="OPEN" selected>OPEN</option>
<option value="PENDING">PENDING</option>
<option value="DONE">DONE</option>
</select>
</label>
<button type="submit" class="BTN is-ok">作成</button>
</form>
</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');
}
// POST以外は弾く
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
// 何も返さない
exit;
}
// titleを受け取る
$title = (string)($_POST['title'] ?? '');
// statusを受け取る
$status = (string)($_POST['status'] ?? 'OPEN');
// 前後空白を除去する
$title = trim($title);
// 前後空白を除去する
$status = trim($status);
// titleが空なら仮の値にする
if ($title === '') $title = '(未入力)';
// status候補
$statuses = ['OPEN', 'PENDING', 'DONE'];
// statusが候補外ならOPENにする
if (!in_array($status, $statuses, true)) $status = 'OPEN';
// 疑似IDを作る(ミリ秒ベース)
$id = (int)floor(microtime(true) * 1000) % 100000;
// 1行分の<tr>だけ返す(afterbeginでtbody先頭に入る)
echo '<tr>';
// ID
echo '<td>' . h((string)$id) . '</td>';
// タイトル
echo '<td>' . h($title) . '</td>';
// ステータス
echo '<td><span class="BADGE">' . h($status) . '</span></td>';
// tr終了
echo '</tr>';
デモ
① 新規作成 → 一覧の先頭に追加
| ID | タイトル | ステータス |
|---|---|---|
| 501 | 申請:アカウント追加 | OPEN |
| 502 | 申請:権限変更 | PENDING |
| 503 | 申請:端末初期化 | DONE |
解説
- フォーム送信(
hx-post)で新規レコードを作成します。 - サーバーは「1行分のHTML(
<tr>)」を返します。 hx-targetをtbodyにして、hx-swap="afterbegin"で先頭に追加します。
② モーダル閉じる + 一覧更新
モーダル内で新規作成し、成功したらモーダルを閉じつつ、一覧を先頭に追加します。
一覧更新は hx-swap-oob を使い、モーダル外の要素も同時に更新します。
HTML
<div class="DEMO">
<h4>② モーダル閉じる + 一覧更新</h4>
<dialog id="DEMO_CREATE_2_MODAL" class="W450 P1rm">
<form
id="DEMO_CREATE_2_FORM"
class="FORM"
method="post"
hx-post="/htmx/demo/_create_record_2.php"
hx-swap="none"
hx-on::after-request="if(event.detail.successful){ document.getElementById('DEMO_CREATE_2_MODAL').close(); this.reset(); }"
>
<h5>新規作成</h5>
<label>
タイトル
<input type="text" name="title" value="申請:メーリングリスト追加">
</label>
<label>
担当
<input type="text" name="owner" value="tanaka">
</label>
<label>
ステータス
<select name="status">
<option value="OPEN" selected>OPEN</option>
<option value="PENDING">PENDING</option>
<option value="DONE">DONE</option>
</select>
</label>
<div class="DIALOG__FOOT">
<button type="submit" class="BTN is-ok">作成</button>
<button type="button" class="BTN is-ghost" onclick="document.getElementById('DEMO_CREATE_2_MODAL').close()">
キャンセル
</button>
</div>
</form>
</dialog>
<div class="TABLE_WRAPPER">
<table class="TABLE">
<thead>
<tr>
<th class="W15pc">ID</th>
<th>タイトル</th>
<th class="W15pc">担当</th>
<th class="W20pc">ステータス</th>
</tr>
</thead>
<tbody id="DEMO_CREATE_2_TBODY">
<tr><td>601</td><td>申請:アカウント追加</td><td>sato</td><td><span class="BADGE">OPEN</span></td></tr>
<tr><td>602</td><td>申請:権限変更</td><td>tanaka</td><td><span class="BADGE">PENDING</span></td></tr>
<tr><td>603</td><td>申請:端末貸与</td><td>suzuki</td><td><span class="BADGE">DONE</span></td></tr>
</tbody>
</table>
</div>
<!-- 成功通知 -->
<div id="DEMO_CREATE_2_NOTICE" class="FORM-RESULT"></div>
<button type="button" class="BTN" onclick="document.getElementById('DEMO_CREATE_2_MODAL').showModal()">
+ 新規作成
</button>
</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');
}
// POST以外は弾く
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
// 何も返さない
exit;
}
// titleを受け取る
$title = (string)($_POST['title'] ?? '');
// ownerを受け取る
$owner = (string)($_POST['owner'] ?? '');
// statusを受け取る
$status = (string)($_POST['status'] ?? 'OPEN');
// 前後空白を除去する
$title = trim($title);
// 前後空白を除去する
$owner = trim($owner);
// 前後空白を除去する
$status = trim($status);
// titleが空なら仮の値にする
if ($title === '') $title = '(未入力)';
// ownerが空なら仮の値にする
if ($owner === '') $owner = '(未入力)';
// status候補
$statuses = ['OPEN', 'PENDING', 'DONE'];
// statusが候補外ならOPENにする
if (!in_array($status, $statuses, true)) $status = 'OPEN';
// 疑似IDを作る(ミリ秒ベース)
$id = (int)floor(microtime(true) * 1000) % 100000;
// (1)モーダル外の通知を更新(OOB)
echo '<div id="DEMO_CREATE_2_NOTICE" class="FORM-RESULT is-ok" hx-swap-oob="true">';
// 通知文を出す
echo '<strong>作成しました。</strong> #' . h((string)$id);
// 閉じる
echo '</div>';
// (2)一覧の先頭に<tr>を追加(OOB)※tableで包んでパース事故を防ぐ
echo '<table><tbody hx-swap-oob="afterbegin:#DEMO_CREATE_2_TBODY">';
// tr開始(OOBで tbody に afterbegin)
echo '<tr>';
// ID
echo '<td>' . h((string)$id) . '</td>';
// タイトル
echo '<td>' . h($title) . '</td>';
// 担当
echo '<td>' . h($owner) . '</td>';
// ステータス
echo '<td><span class="BADGE">' . h($status) . '</span></td>';
// tr終了
echo '</tr>';
// tableの閉じ
echo '</tbody></table>';
デモ
② モーダル閉じる + 一覧更新
| ID | タイトル | 担当 | ステータス |
|---|---|---|---|
| 601 | 申請:アカウント追加 | sato | OPEN |
| 602 | 申請:権限変更 | tanaka | PENDING |
| 603 | 申請:端末貸与 | suzuki | DONE |
解説
- 作成フォームはモーダル(
<dialog>)内で送信します。 - レスポンスは
hx-swap="none"にして、画面への通常差し込みを行いません。 - 代わりに
hx-swap-oobを使い、モーダル外の一覧(tbody)へ先頭追加します。 - 送信成功後は
hx-on::after-requestでモーダルを閉じ、フォームをリセットします。
③ 集計も同時更新
新規作成と同時に、一覧の先頭追加 + 集計カード更新もまとめて行います。
返却HTMLは hx-swap-oob だけで処理し、DOM崩れを防いだ安定構成です。
HTML
<div class="DEMO">
<h4>③ 集計も同時更新</h4>
<!-- 集計カード(OOBで丸ごと差し替え) -->
<div id="DEMO_CREATE_3_STATS" class="CARD">
<div><strong>合計:</strong> <span>3</span> 件</div>
<div><strong>OPEN:</strong> <span>1</span></div>
<div><strong>PENDING:</strong> <span>1</span></div>
<div><strong>DONE:</strong> <span>1</span></div>
</div>
<div class="TABLE_WRAPPER">
<table class="TABLE">
<thead>
<tr><th>ID</th><th>タイトル</th><th>ステータス</th></tr>
</thead>
<tbody id="DEMO_CREATE_3_TBODY">
<tr><td>701</td><td>申請:アカウント追加</td><td><span class="BADGE">OPEN</span></td></tr>
<tr><td>702</td><td>申請:権限変更</td><td><span class="BADGE">PENDING</span></td></tr>
<tr><td>703</td><td>申請:端末貸与</td><td><span class="BADGE">DONE</span></td></tr>
</tbody>
</table>
</div>
<!-- 通知(OOBで差し替え) -->
<div id="DEMO_CREATE_3_NOTICE" class="FORM-RESULT">
<span class="HTMX-NOTE">作成すると「一覧」と「集計」が同時更新されます。</span>
</div>
<form
id="DEMO_CREATE_3_FORM"
class="FORM"
method="post"
hx-post="/htmx/demo/_create_record_3.php"
hx-swap="none"
>
<!-- 状態(集計用)をフォームに保持(サーバーに送る) -->
<input id="DEMO_CREATE_3_STATE" type="hidden" name="state" value="701:OPEN|702:PENDING|703:DONE">
<label>
タイトル
<input type="text" name="title" value="申請:新規アカウント発行">
</label>
<label>
ステータス
<select name="status">
<option value="OPEN" selected>OPEN</option>
<option value="PENDING">PENDING</option>
<option value="DONE">DONE</option>
</select>
</label>
<button type="submit" class="BTN is-ok">作成</button>
</form>
</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');
}
// POST以外は弾く
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
// 何も返さない
exit;
}
// titleを受け取る
$title = (string)($_POST['title'] ?? '');
// statusを受け取る
$status = (string)($_POST['status'] ?? 'OPEN');
// stateを受け取る(例:701:OPEN|702:PENDING|703:DONE)
$state = (string)($_POST['state'] ?? '');
// 前後空白を除去する
$title = trim($title);
// 前後空白を除去する
$status = trim($status);
// 前後空白を除去する
$state = trim($state);
// titleが空なら仮の値にする
if ($title === '') $title = '(未入力)';
// status候補
$statuses = ['OPEN', 'PENDING', 'DONE'];
// statusが候補外ならOPENにする
if (!in_array($status, $statuses, true)) $status = 'OPEN';
// 既存アイテムを格納する配列
$items = [];
// stateが空でなければ分解する
if ($state !== '') {
// 行の区切りで分割する
$rows = explode('|', $state);
// 1件ずつ見る
foreach ($rows as $row) {
// 前後空白を除去する
$row = trim($row);
// 空なら無視
if ($row === '') continue;
// id:status を分割する
$pair = explode(':', $row, 2);
// 要素が不足していたら無視
if (!isset($pair[0], $pair[1])) continue;
// idを取り出す
$idRaw = trim((string)$pair[0]);
// statusを取り出す
$stRaw = trim((string)$pair[1]);
// idが数字でなければ無視
if (!preg_match('/^\d+$/', $idRaw)) continue;
// statusが候補外なら無視
if (!in_array($stRaw, $statuses, true)) continue;
// itemsに入れる
$items[] = ['id' => (int)$idRaw, 'status' => $stRaw];
}
}
// 最大IDを取る(なければ700)
$maxId = 700;
// itemsから最大値を探す
foreach ($items as $it) {
// idを取り出す
$iid = (int)$it['id'];
// 大きければ更新する
if ($iid > $maxId) $maxId = $iid;
}
// 新しいIDを作る
$newId = $maxId + 1;
// 先頭に追加する
array_unshift($items, ['id' => $newId, 'status' => $status]);
// 集計用カウント
$total = count($items);
// OPEN数
$cntOpen = 0;
// PENDING数
$cntPending = 0;
// DONE数
$cntDone = 0;
// ステータス別に数える
foreach ($items as $it) {
// statusを取り出す
$st = (string)$it['status'];
// OPENなら加算
if ($st === 'OPEN') $cntOpen++;
// PENDINGなら加算
if ($st === 'PENDING') $cntPending++;
// DONEなら加算
if ($st === 'DONE') $cntDone++;
}
// state文字列を作り直す
$newStateParts = [];
// stateに戻す
foreach ($items as $it) {
// idを取り出す
$iid = (int)$it['id'];
// statusを取り出す
$st = (string)$it['status'];
// 文字列化して入れる
$newStateParts[] = (string)$iid . ':' . $st;
}
// 連結する
$newState = implode('|', $newStateParts);
// (1)通知を更新(OOB)
echo '<div id="DEMO_CREATE_3_NOTICE" class="FORM-RESULT is-ok" hx-swap-oob="true">';
// 通知文を出す
echo '<strong>作成しました。</strong> #' . h((string)$newId);
// 閉じる
echo '</div>';
// (2)一覧の先頭に<tr>を追加(OOB)※tbodyをtableで包むとtrが安定してパースされる
echo '<table><tbody hx-swap-oob="afterbegin:#DEMO_CREATE_3_TBODY">';
// tr開始(追加行だけハイライトする)
echo '<tr id="DEMO_CREATE_3_ROW_' . h((string)$newId) . '" class="TR-FLASH">';
// ID
echo '<td>' . h((string)$newId) . '</td>';
// タイトル
echo '<td>' . h($title) . '</td>';
// ステータス
echo '<td><span class="BADGE">' . h($status) . '</span></td>';
// tr終了
echo '</tr>';
// table閉じ
echo '</tbody></table>';
// (3)集計カードを更新(OOBで丸ごと差し替え)
echo '<div id="DEMO_CREATE_3_STATS" class="CARD" hx-swap-oob="true">';
// 合計
echo '<div><strong>合計:</strong> <span>' . h((string)$total) . '</span> 件</div>';
// OPEN
echo '<div><strong>OPEN:</strong> <span>' . h((string)$cntOpen) . '</span></div>';
// PENDING
echo '<div><strong>PENDING:</strong> <span>' . h((string)$cntPending) . '</span></div>';
// DONE
echo '<div><strong>DONE:</strong> <span>' . h((string)$cntDone) . '</span></div>';
// 閉じる
echo '</div>';
// (4)内部状態(hidden)も更新(次回以降の集計のため)
echo '<input id="DEMO_CREATE_3_STATE" type="hidden" name="state" value="' . h($newState) . '" hx-swap-oob="true">';
CSS
/* 追加された行を一瞬だけハイライト */
.TR-FLASH{
animation: TR_FLASH_BG 1.2s ease-out;
}
@keyframes TR_FLASH_BG{
0% { background-color: rgba(255, 165, 0, 0.35); }
100% { background-color: transparent; }
}
デモ
③ 集計も同時更新
合計: 3 件
OPEN: 1
PENDING: 1
DONE: 1
| ID | タイトル | ステータス |
|---|---|---|
| 701 | 申請:アカウント追加 | OPEN |
| 702 | 申請:権限変更 | PENDING |
| 703 | 申請:端末貸与 | DONE |
作成すると「一覧」と「集計」が同時更新されます。
解説
- 作成と同時に「一覧先頭追加」「集計カード更新」をまとめて行います。
- 集計カードは
hx-swap-oob="true"で丸ごと差し替えます。 - 次回集計用に
hiddenの状態(state)も同時に更新します。 - 追加された行にはクラスを付け、CSSアニメーションで一瞬だけハイライトします。
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール