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-targettbody にして、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申請:アカウント追加satoOPEN
602申請:権限変更tanakaPENDING
603申請:端末貸与suzukiDONE

解説

  • 作成フォームはモーダル(<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アニメーションで一瞬だけハイライトします。

このページの著者

もちもちみかん(システムエンジニア)

社内SEとしてグループ企業向けの業務アプリを要件定義〜運用まで一気通貫で担当しています。

経験:Webアプリ/業務システム

得意:PHP・JavaScript・MySQL・CSS

個人実績:フォーム生成基盤クイズ学習プラットフォーム

詳しいプロフィールはこちら!  もちもちみかんのプロフィール

もちもちみかん0系くん
TOPへ

もちもちみかん.comとは


このサイトでは、コーディングがめんどうくさい人向けのお助けツールとして、フォームやCSSをノーコードで生成できる、
 もちもちみかん.forms
 もちもちみかん.css1
 もちもちみかん.css2
と言ったジェネレーターを用意してます。

また、このサイトを通じて、「もちもちみかん」のかわいさを普及したいとかんがえてます!