htmx 逆引きレシピ
テーブルをソートするには?

公開日:
最終更新日:

テーブルの一覧は、日付・金額・ステータスなどで並び替えできると、探す手間が一気に減ります。
htmxなら、ヘッダクリックで必要部分だけ再描画しつつ、URLにも状態を残せるソートUIが手軽に作れます。

このページでは「最小構成」「ヘッダクリックで再描画」「ソートをURLにも反映」の3例で、 hx-get / hx-vals / hx-target / hx-push-url を使った実装を解説します。

使用するhtmx属性

  • hx-get:ヘッダクリックで並び替え条件をGETで送り、テーブルHTMLを取得する
  • hx-vals:リンクのクエリに依存せず、送るパラメータ(sort/dir)を明示する
  • hx-target:返ってきたHTMLを差し替える先(テーブルコンテナ)を指定する
  • hx-push-url:並び替え状態をURLに反映して、リロード/直リンクに対応する

利用シーン

  • 「日付/金額/ステータスで並び替え」:請求・申請・発注などの一覧を用途別に見やすくしたい
  • 「ヘッダクリックで再描画」:ページ全体は動かさず、テーブル部分だけ更新したい
  • 「URLにも反映」:並び替え状態をURLに残し、リロード/共有で同じ並びを再現したい

共通PHP

PHP(_table_sort_data.php)

<?php
// HTMLエスケープ関数を用意する
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }

// デモ用の行データを用意する
$TABLE_SORT_ROWS = [
	['id' => 501, 'title' => '請求書の発行', 'date' => '2026-01-18', 'amount' => 12800, 'status' => 'processing'],
	['id' => 502, 'title' => '備品購入(マウス)', 'date' => '2026-01-12', 'amount' => 2800,  'status' => 'done'],
	['id' => 503, 'title' => '出張申請(札幌)', 'date' => '2026-01-09', 'amount' => 42000, 'status' => 'pending'],
	['id' => 504, 'title' => '契約更新の確認', 'date' => '2026-01-05', 'amount' => 0,     'status' => 'processing'],
	['id' => 505, 'title' => '交通費精算',       'date' => '2025-12-28', 'amount' => 3600,  'status' => 'done'],
	['id' => 506, 'title' => '研修キャンセル',   'date' => '2025-12-26', 'amount' => 15000, 'status' => 'canceled'],
	['id' => 507, 'title' => '請求書の再発行',   'date' => '2025-12-20', 'amount' => 12800, 'status' => 'pending'],
];

// ステータスの表示ラベル
$TABLE_SORT_STATUS_LABELS = [
	'pending'    => '未処理',
	'processing' => '処理中',
	'done'       => '完了',
	'canceled'   => 'キャンセル',
];

// ステータスの並び順(小さいほど優先)
$TABLE_SORT_STATUS_ORDER = [
	'pending'    => 1,
	'processing' => 2,
	'done'       => 3,
	'canceled'   => 4,
];

// sort / dir を取得して検証する関数
function table_sort_get_params(array $get): array {
	// sortを取得する(デフォルトはdate)
	$sort = (string)($get['sort'] ?? 'date');
	// dirを取得する(デフォルトはdesc)
	$dir  = (string)($get['dir'] ?? 'desc');
	// 前後空白を除去する
	$sort = trim($sort);
	$dir  = trim($dir);
	// 許可された値だけを受け付ける
	$allowedSort = ['date', 'amount', 'status'];
	$allowedDir  = ['asc', 'desc'];
	// sortが不正ならデフォルト
	if (!in_array($sort, $allowedSort, true)) $sort = 'date';
	// dirが不正ならデフォルト
	if (!in_array($dir, $allowedDir, true)) $dir = 'desc';
	// 検証済みを返す
	return [$sort, $dir];
}

// 並び替えを行う関数
function table_sort_sort_rows(array $rows, string $sort, string $dir, array $statusOrder): array {
	// 昇順/降順の係数
	$mul = ($dir === 'desc') ? -1 : 1;
	// 並び替え
	usort($rows, function(array $a, array $b) use ($sort, $mul, $statusOrder): int {

		// 比較結果
		$cmp = 0;

		// 列ごとの比較
		switch ($sort) {
			case 'amount':
				$cmp = ((int)$a['amount']) <=> ((int)$b['amount']);
				break;
			case 'status':
				$cmp = ((int)($statusOrder[$a['status']] ?? 999)) <=> ((int)($statusOrder[$b['status']] ?? 999));
				break;
			case 'date':
			default:
				$cmp = strtotime((string)$a['date']) <=> strtotime((string)$b['date']);
				break;
		}

		// 同値ならIDで安定化する
		if ($cmp === 0) {
			$cmp = ((int)$a['id']) <=> ((int)$b['id']);
		}

		// 昇降順を反映して返す
		return $cmp * $mul;
	});
	// 並び替え済みを返す
	return array_values($rows);
}

// aria-sort を組み立てる関数
function table_sort_aria_sort(string $column, string $sort, string $dir): string {
	// 現在列以外はnone
	if ($column !== $sort) return 'none';
	// 現在列の昇降順
	return ($dir === 'asc') ? 'ascending' : 'descending';
}

// 見出しの矢印を返す関数
function table_sort_indicator(string $column, string $sort, string $dir): string {
	// 現在列以外は空
	if ($column !== $sort) return '';
	// 昇降順の矢印
	return ($dir === 'asc')
		? ' <span aria-hidden="true">▲</span>'
		: ' <span aria-hidden="true">▼</span>';
}

// 次の並び順を返す関数
function table_sort_next_dir(string $column, string $sort, string $dir): string {
	// 同じ列なら昇降順を反転
	if ($column === $sort) return ($dir === 'asc') ? 'desc' : 'asc';
	// 別列なら昇順から
	return 'asc';
}

① 日付/金額/ステータスで並び替え

ヘッダリンクにhx-getを付け、結果テーブルだけ差し替える最小構成です。
まずは「並び替えできること」を一番シンプルに確認したいときに向きます。

HTML

<div class="DEMO">

	<h4>① 日付/金額/ステータスで並び替え</h4>

	<div
		id="DEMO_SORT_1_RESULT"
		class="RESULT"
		aria-live="polite"

		hx-get="/htmx/demo/_table_sort_1.php"
		hx-trigger="load"
		hx-target="this"
	>
		<span class="HTMX-NOTE">読み込み中...</span>
	</div>

</div>

PHP

<?php
// 型を厳密に扱う
declare(strict_types=1);

// 返すのはHTML(UTF-8)
header('Content-Type: text/html; charset=UTF-8');

// 共通データ/関数を読み込む
require __DIR__ . '/_table_sort_data.php';

// sort / dir を取得して検証する
[$sort, $dir] = table_sort_get_params($_GET);

// 並び替え済みの行を取得する
$rows = table_sort_sort_rows($TABLE_SORT_ROWS, $sort, $dir, $TABLE_SORT_STATUS_ORDER);

// 表示用ラベル
$sortLabels = ['date' => '日付', 'amount' => '金額', 'status' => 'ステータス'];
$dirLabels  = ['asc' => '昇順', 'desc' => '降順'];

// 見出しを表示する
echo '<div class="RESULT-HEAD">';
echo '<strong>並び替え:</strong> ' . h($sortLabels[$sort]) . '(' . h($dirLabels[$dir]) . ')';
echo ' <strong>' . count($rows) . '件</strong>';
echo '</div>';

// テーブル開始
echo '<div class="TABLE_WRAPPER">';
echo '<table class="TABLE">';
echo '<thead><tr>';

// ヘッダ(並び替えリンク)
$targetId = 'DEMO_SORT_1_RESULT';
$baseUrl  = '/htmx/demo/_table_sort_1.php';

$nextDate = table_sort_next_dir('date', $sort, $dir);
$nextAmt  = table_sort_next_dir('amount', $sort, $dir);
$nextSt   = table_sort_next_dir('status', $sort, $dir);

$urlDate = $baseUrl . '?sort=date&dir=' . $nextDate;
$urlAmt  = $baseUrl . '?sort=amount&dir=' . $nextAmt;
$urlSt   = $baseUrl . '?sort=status&dir=' . $nextSt;

$ariaDate = table_sort_aria_sort('date', $sort, $dir);
$ariaAmt  = table_sort_aria_sort('amount', $sort, $dir);
$ariaSt   = table_sort_aria_sort('status', $sort, $dir);

$iconDate = table_sort_indicator('date', $sort, $dir);
$iconAmt  = table_sort_indicator('amount', $sort, $dir);
$iconSt   = table_sort_indicator('status', $sort, $dir);

// 日付
echo '<th aria-sort="' . h($ariaDate) . '">'
	. '<a href="' . h($urlDate) . '" hx-get="' . h($urlDate) . '" hx-target="#' . h($targetId) . '">日付' . $iconDate . '</a>'
	. '</th>';

// 内容(固定列)
echo '<th>内容</th>';

// 金額
echo '<th aria-sort="' . h($ariaAmt) . '">'
	. '<a href="' . h($urlAmt) . '" hx-get="' . h($urlAmt) . '" hx-target="#' . h($targetId) . '">金額' . $iconAmt . '</a>'
	. '</th>';

// ステータス
echo '<th aria-sort="' . h($ariaSt) . '">'
	. '<a href="' . h($urlSt) . '" hx-get="' . h($urlSt) . '" hx-target="#' . h($targetId) . '">ステータス' . $iconSt . '</a>'
	. '</th>';

// ヘッダ終了
echo '</tr></thead>';

// 本体開始
echo '<tbody>';

// 1行ずつ表示
foreach ($rows as $r) {

	// 日付
	$date = h((string)$r['date']);

	// 内容
	$title = h((string)$r['title']);

	// 金額
	$amount = h(number_format((int)$r['amount']));

	// ステータス
	$status = h($TABLE_SORT_STATUS_LABELS[$r['status']] ?? (string)$r['status']);

	// 1行出力
	echo "<tr><td>{$date}</td><td>{$title}</td><td>{$amount}</td><td>{$status}</td></tr>";
}

// tbody終了
echo '</tbody>';

// table終了
echo '</table></div>';

デモ

① 日付/金額/ステータスで並び替え

読み込み中...

解説

  • ヘッダのリンクにhx-getを付け、クリックで並び替え結果のHTMLを取得します。
  • hx-targetで結果エリアを指定し、テーブル部分だけを差し替えます。
  • PHP側はsortdirを検証し、日付/金額/ステータスを安全に並び替えます。
  • 現在の並び替え列はaria-sortと▲▼で示し、UI状態を明示します。

② ヘッダクリックで再描画

ヘッダのリンクURLとは独立して、送信するパラメータをhx-valsで明示する例です。
クエリ直書きに依存しないので、UI側の表示URLを自由に設計できます。

HTML

<div class="DEMO">

	<h4>② ヘッダクリックで再描画</h4>

	<div
		id="DEMO_SORT_2_RESULT"
		class="RESULT"
		aria-live="polite"

		hx-get="/htmx/demo/_table_sort_2.php"
		hx-trigger="load"
		hx-target="this"
	>
		<span class="HTMX-NOTE">読み込み中...</span>
	</div>

</div>

PHP

<?php
// 型を厳密に扱う
declare(strict_types=1);

// 返すのはHTML(UTF-8)
header('Content-Type: text/html; charset=UTF-8');

// 共通データ/関数を読み込む
require __DIR__ . '/_table_sort_data.php';

// sort / dir を取得して検証する
[$sort, $dir] = table_sort_get_params($_GET);

// 並び替え済みの行を取得する
$rows = table_sort_sort_rows($TABLE_SORT_ROWS, $sort, $dir, $TABLE_SORT_STATUS_ORDER);

// 表示用ラベル
$sortLabels = ['date' => '日付', 'amount' => '金額', 'status' => 'ステータス'];
$dirLabels  = ['asc' => '昇順', 'desc' => '降順'];

// 見出しを表示する
echo '<div class="RESULT-HEAD">';
echo '<strong>並び替え:</strong> ' . h($sortLabels[$sort]) . '(' . h($dirLabels[$dir]) . ')';
echo ' <strong>' . count($rows) . '件</strong>';
echo '</div>';

// テーブル開始
echo '<div class="TABLE_WRAPPER">';
echo '<table class="TABLE">';
echo '<thead><tr>';

// ヘッダ(hx-valsで送信)
$targetId = 'DEMO_SORT_2_RESULT';
$baseUrl  = '/htmx/demo/_table_sort_2.php';

$nextDate = table_sort_next_dir('date', $sort, $dir);
$nextAmt  = table_sort_next_dir('amount', $sort, $dir);
$nextSt   = table_sort_next_dir('status', $sort, $dir);

$urlDate = $baseUrl . '?sort=date&dir=' . $nextDate;
$urlAmt  = $baseUrl . '?sort=amount&dir=' . $nextAmt;
$urlSt   = $baseUrl . '?sort=status&dir=' . $nextSt;

$ariaDate = table_sort_aria_sort('date', $sort, $dir);
$ariaAmt  = table_sort_aria_sort('amount', $sort, $dir);
$ariaSt   = table_sort_aria_sort('status', $sort, $dir);

$iconDate = table_sort_indicator('date', $sort, $dir);
$iconAmt  = table_sort_indicator('amount', $sort, $dir);
$iconSt   = table_sort_indicator('status', $sort, $dir);

$valsDate = h(json_encode(['sort' => 'date',   'dir' => $nextDate], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$valsAmt  = h(json_encode(['sort' => 'amount', 'dir' => $nextAmt ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$valsSt   = h(json_encode(['sort' => 'status', 'dir' => $nextSt  ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

// 日付
echo '<th aria-sort="' . h($ariaDate) . '">'
	. '<a href="' . h($urlDate) . '" hx-get="' . h($baseUrl) . '" hx-vals=\'' . $valsDate . '\' hx-target="#' . h($targetId) . '">日付' . $iconDate . '</a>'
	. '</th>';

// 内容(固定列)
echo '<th>内容</th>';

// 金額
echo '<th aria-sort="' . h($ariaAmt) . '">'
	. '<a href="' . h($urlAmt) . '" hx-get="' . h($baseUrl) . '" hx-vals=\'' . $valsAmt . '\' hx-target="#' . h($targetId) . '">金額' . $iconAmt . '</a>'
	. '</th>';

// ステータス
echo '<th aria-sort="' . h($ariaSt) . '">'
	. '<a href="' . h($urlSt) . '" hx-get="' . h($baseUrl) . '" hx-vals=\'' . $valsSt . '\' hx-target="#' . h($targetId) . '">ステータス' . $iconSt . '</a>'
	. '</th>';

// ヘッダ終了
echo '</tr></thead>';

// 本体開始
echo '<tbody>';

// 1行ずつ表示
foreach ($rows as $r) {

	// 日付
	$date = h((string)$r['date']);

	// 内容
	$title = h((string)$r['title']);

	// 金額
	$amount = h(number_format((int)$r['amount']));

	// ステータス
	$status = h($TABLE_SORT_STATUS_LABELS[$r['status']] ?? (string)$r['status']);

	// 1行出力
	echo "<tr><td>{$date}</td><td>{$title}</td><td>{$amount}</td><td>{$status}</td></tr>";
}

// tbody終了
echo '</tbody>';

// table終了
echo '</table></div>';

デモ

② ヘッダクリックで再描画

読み込み中...

解説

  • ヘッダリンクのURLはそのまま残し、送信する値だけをhx-valsで指定しています。
  • クエリ直書きに依存しないため、URL設計を変えても送信値の制御が簡単です。
  • PHP側はsort/dirをホワイトリスト検証し、許可された値だけでソートします。
  • hx-targetでテーブル部分だけを更新し、ページ全体は再描画しません。

③ ソートをURLにも反映

並び替え状態(sort/dir)をURLに反映し、リロード/直リンクでも同じ状態を再現します。
ヘッダクリック時にURLを書き換え、初回ロード時はURLの値を読み取って表示します。

HTML

<div class="DEMO">

	<h4>③ ソートをURLにも反映</h4>

	<div
		id="DEMO_SORT_3_RESULT"
		class="RESULT"
		aria-live="polite"

		hx-get="/htmx/demo/_table_sort_3.php"
		hx-trigger="load"
		hx-vals='js:{sort:(new URLSearchParams(location.search).get("sort")||"date"),dir:(new URLSearchParams(location.search).get("dir")||"desc")}'
		hx-target="this"
	>
		<span class="HTMX-NOTE">読み込み中...</span>
	</div>

</div>

PHP

<?php
// 型を厳密に扱う
declare(strict_types=1);

// 返すのはHTML(UTF-8)
header('Content-Type: text/html; charset=UTF-8');

// 共通データ/関数を読み込む
require __DIR__ . '/_table_sort_data.php';

// sort / dir を取得して検証する
[$sort, $dir] = table_sort_get_params($_GET);

// 並び替え済みの行を取得する
$rows = table_sort_sort_rows($TABLE_SORT_ROWS, $sort, $dir, $TABLE_SORT_STATUS_ORDER);

// 表示用ラベル
$sortLabels = ['date' => '日付', 'amount' => '金額', 'status' => 'ステータス'];
$dirLabels  = ['asc' => '昇順', 'desc' => '降順'];

// 見出しを表示する
echo '<div class="RESULT-HEAD">';
echo '<strong>並び替え:</strong> ' . h($sortLabels[$sort]) . '(' . h($dirLabels[$dir]) . ')';
echo ' <strong>' . count($rows) . '件</strong>';
echo '</div>';

// テーブル開始
echo '<div class="TABLE_WRAPPER">';
echo '<table class="TABLE">';
echo '<thead><tr>';

// ヘッダ(hx-push-urlでURL更新)
$targetId = 'DEMO_SORT_3_RESULT';
$baseUrl  = '/htmx/demo/_table_sort_3.php';

$nextDate = table_sort_next_dir('date', $sort, $dir);
$nextAmt  = table_sort_next_dir('amount', $sort, $dir);
$nextSt   = table_sort_next_dir('status', $sort, $dir);

$pageDate = '?sort=date&dir=' . $nextDate;
$pageAmt  = '?sort=amount&dir=' . $nextAmt;
$pageSt   = '?sort=status&dir=' . $nextSt;

$demoDate = $baseUrl . $pageDate;
$demoAmt  = $baseUrl . $pageAmt;
$demoSt   = $baseUrl . $pageSt;

$ariaDate = table_sort_aria_sort('date', $sort, $dir);
$ariaAmt  = table_sort_aria_sort('amount', $sort, $dir);
$ariaSt   = table_sort_aria_sort('status', $sort, $dir);

$iconDate = table_sort_indicator('date', $sort, $dir);
$iconAmt  = table_sort_indicator('amount', $sort, $dir);
$iconSt   = table_sort_indicator('status', $sort, $dir);

// 日付
echo '<th aria-sort="' . h($ariaDate) . '">'
	. '<a href="' . h($pageDate) . '" hx-get="' . h($demoDate) . '" hx-target="#' . h($targetId) . '" hx-push-url="' . h($pageDate) . '">日付' . $iconDate . '</a>'
	. '</th>';

// 内容(固定列)
echo '<th>内容</th>';

// 金額
echo '<th aria-sort="' . h($ariaAmt) . '">'
	. '<a href="' . h($pageAmt) . '" hx-get="' . h($demoAmt) . '" hx-target="#' . h($targetId) . '" hx-push-url="' . h($pageAmt) . '">金額' . $iconAmt . '</a>'
	. '</th>';

// ステータス
echo '<th aria-sort="' . h($ariaSt) . '">'
	. '<a href="' . h($pageSt) . '" hx-get="' . h($demoSt) . '" hx-target="#' . h($targetId) . '" hx-push-url="' . h($pageSt) . '">ステータス' . $iconSt . '</a>'
	. '</th>';

// ヘッダ終了
echo '</tr></thead>';

// 本体開始
echo '<tbody>';

// 1行ずつ表示
foreach ($rows as $r) {

	// 日付
	$date = h((string)$r['date']);

	// 内容
	$title = h((string)$r['title']);

	// 金額
	$amount = h(number_format((int)$r['amount']));

	// ステータス
	$status = h($TABLE_SORT_STATUS_LABELS[$r['status']] ?? (string)$r['status']);

	// 1行出力
	echo "<tr><td>{$date}</td><td>{$title}</td><td>{$amount}</td><td>{$status}</td></tr>";
}

// tbody終了
echo '</tbody>';

// table終了
echo '</table></div>';

デモ

③ ソートをURLにも反映

読み込み中...

解説

  • hx-push-urlでURLに?sort=...&dir=...を反映し、ブラウザ履歴と共有を可能にします。
  • 初回ロード時はhx-valsjs:でURLのクエリを読み取り、同じ並び替え状態で表示します。
  • ヘッダリンクは「表示用URL」と「取得用URL」を分け、結果はhx-targetで差し替えます。
  • これにより、リロード/直リンクでも同じ並び順を再現できます。

次に読むオススメレシピ

このページの著者

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

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

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

得意:PHP・JavaScript・MySQL・CSS

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

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

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

もちもちみかん.comとは


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

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