htmx 逆引きレシピ
フィルタ/ページングでURLも更新するには?

公開日:
最終更新日:

一覧検索は便利ですが、「条件を共有できない」「戻る/進むで壊れる」「リロードで状態が消える」問題が起きがちです。
htmxなら、フィルタとページングに合わせてURLも更新し、状態を“URLに乗せて再現”できます。

このページでは、検索条件を共有しつつ、戻る/進むも自然に動き、さらにURLだけで状態を再現できる構成を1つのデモで作ります。
ポイントは「フィルタは replace」「ページングは push」の使い分けです。

使用するhtmx属性

  • hx-get:フィルタ/ページ変更のたびに、GETで一覧HTMLを取得して更新する
  • hx-trigger:入力中・変更時など、リクエストを発火するタイミングを指定(例:keyup changed delay:350ms
  • hx-replace-url:URLだけ更新して履歴は増やさない(ライブ検索で履歴が積み上がるのを防ぐ)
  • hx-push-url:URLを更新しつつ履歴に積む(ページングで「戻る/進む」を自然にする)
  • hx-target:差し替える先の要素(CSSセレクタ)を指定(例:#URL_APP
  • hx-swap:差し替え方を指定(例:outerHTML で“フォーム+結果”を丸ごと更新)

利用シーン

  • 「検索条件を共有したい」:URLをコピーして、同じ絞り込み結果を他メンバーに渡したい
  • 「戻る/進むを壊したくない」:ページング後もブラウザの履歴操作が自然に動いてほしい
  • 「URLで状態を再現したい」:リロード/再訪問しても、同じ条件・同じページを復元したい

フィルタ/ページングでURLも更新する(共有・戻る進む・再現)

検索条件をURLに反映し、コピー共有できるようにします。
ページングは履歴に積むので、ブラウザの戻る/進むでも状態が崩れません。

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');
}

// 検索キーワードを受け取る
$q = (string)($_GET['q'] ?? '');

// ステータスを受け取る(ALL / OPEN / PENDING / DONE)
$status = (string)($_GET['status'] ?? 'ALL');

// ページ番号を受け取る
$page = (int)($_GET['page'] ?? 1);

// 前後空白を除去する
$q = trim($q);

// 前後空白を除去する
$status = trim($status);

// ページが1未満なら1にする
if ($page < 1) $page = 1;

// ステータス候補を定義する
$statuses = ['ALL', 'OPEN', 'PENDING', 'DONE'];

// ステータスが不正ならALLにする
if (!in_array($status, $statuses, true)) $status = 'ALL';

// 1ページあたりの表示件数
$perPage = 8;

// ダミーデータ件数
$totalRows = 67;

// 申請データの配列を作る
$rows = [];

// 1件ずつ作る
for ($i = 1; $i <= $totalRows; $i++) {

	// IDを作る
	$id = 700 + $i;

	// タイトル候補を用意する
	$titles = [
		'申請:アカウント追加',
		'申請:権限変更',
		'申請:端末貸与',
		'申請:VPN追加',
		'申請:メーリングリスト追加',
		'申請:共有フォルダ作成',
	];

	// 担当候補を用意する
	$owners = ['tanaka', 'sato', 'suzuki', 'kato'];

	// ステータス候補を用意する
	$stList = ['OPEN', 'PENDING', 'DONE'];

	// タイトルを選ぶ
	$title = $titles[$i % count($titles)];

	// 担当を選ぶ
	$owner = $owners[$i % count($owners)];

	// ステータスを選ぶ
	$st = $stList[$i % count($stList)];

	// 配列に積む
	$rows[] = [
		'id'     => $id,
		'title'  => $title,
		'owner'  => $owner,
		'status' => $st,
	];
}

// フィルタ後の配列
$filtered = [];

// 1件ずつ見る
foreach ($rows as $r) {

	// キーワードが空でなければ判定する
	if ($q !== '') {

		// タイトルに含まれるか判定する
		$hitTitle = (mb_stripos((string)$r['title'], $q) !== false);

		// 担当に含まれるか判定する
		$hitOwner = (mb_stripos((string)$r['owner'], $q) !== false);

		// どちらにも当たらないならスキップする
		if (!$hitTitle && !$hitOwner) continue;
	}

	// ステータスがALLでなければ判定する
	if ($status !== 'ALL') {

		// 一致しないならスキップする
		if ((string)$r['status'] !== $status) continue;
	}

	// 条件を満たすので採用する
	$filtered[] = $r;
}

// フィルタ後の件数
$filteredCount = count($filtered);

// 総ページ数を計算する
$totalPages = (int)max(1, (int)ceil($filteredCount / $perPage));

// ページが総ページ数を超えたら最後に合わせる
if ($page > $totalPages) $page = $totalPages;

// 開始オフセットを計算する
$offset = ($page - 1) * $perPage;

// 表示する分だけ取り出す
$viewRows = array_slice($filtered, $offset, $perPage);

// ページボタンを常に5個にする(表示範囲)
$window = 5;

// 表示範囲の開始ページ
$startPage = max(1, $page - intdiv($window, 2));

// 表示範囲の終了ページ
$endPage = $startPage + $window - 1;

// 終了が総ページを超えるなら補正する
if ($endPage > $totalPages) {

	// 終了を総ページに合わせる
	$endPage = $totalPages;

	// 開始を逆算する
	$startPage = max(1, $endPage - $window + 1);
}

// ベースURL(このページ自身)
$self = '/htmx/demo/_filter_paging_url.php';

// URL用の共通パラメータ
$baseParams = [
	'q'      => $q,
	'status' => $status,
];
?>
<!doctype html>
<html lang="ja">
<head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width,initial-scale=1">
	<title>フィルタ/ページングでURLも更新 | htmx逆引きレシピ</title>

	<link rel="stylesheet" href="/css/C01.destyle.css?2026-01-03">
	<link rel="stylesheet" href="/css/C11.vars.css?2026-01-03">

	<link rel="stylesheet" href="/css/C12.str.css?2026-01-03">
	<link rel="stylesheet" href="/css/C13.num.css?2026-01-03">
	<link rel="stylesheet" href="/css/C14.color.css?2026-01-03">
	<link rel="stylesheet" href="/css/C21.base.css?2026-01-03">
	<link rel="stylesheet" href="/css/C22.form.css?2026-01-03">
	<link rel="stylesheet" href="/css/C23.parts.css?2026-01-03">
	<link rel="stylesheet" href="/css/C31.calendar.css?2026-01-03">
	<link rel="stylesheet" href="/css/C32.table.css?2026-01-03">
</head>
<body class="P1rm W800 CENTER">

	<section class="SECTION">

		<h1 class="FS2rm">フィルタ/ページングでURLも更新するには?</h1>

		<p class="HTMX-NOTE">
			フィルタ変更は <code>hx-replace-url</code> でURLだけ更新(履歴を増やさない)。<br>
			ページングは <code>hx-push-url</code> で履歴に積み、戻る/進むでも状態を崩しません。
		</p>

	</section>

	<!--
		#URL_APP を丸ごと差し替えるのがコツ
		- 戻る/進むでもフォーム値と結果がズレない
		- URLで開き直せば同じ状態が再現できる
	-->
	<section id="URL_APP" class="SECTION">

		<!--
			このフォームが“ライブ検索+URL更新”の本体
			- hx-get:GETで再検索
			- hx-trigger:入力/変更のたびに発火(delayで連打防止)
			- hx-target/hx-swap:#URL_APP を丸ごと置換
			- hx-replace-url:履歴を増やさずURLだけ更新(共有用)
		-->
		<form
			id="URL_FORM"
			class="FORM"
			method="get"
			action="<?= h($self) ?>"
			hx-get="<?= h($self) ?>"
			hx-trigger="keyup changed delay:350ms, change, submit"
			hx-target="#URL_APP"
			hx-swap="outerHTML"
			hx-replace-url="true"
		>

			<!-- pageもURLで再現するため送る -->
			<input type="hidden" name="page" value="<?= h((string)$page) ?>">

			<label>
				キーワード(タイトル/担当)
				<input type="text" name="q" maxlength="64" value="<?= h($q) ?>" placeholder="例:tanaka / VPN / 端末">
			</label>

			<label>
				ステータス
				<select name="status">
					<option value="ALL" <?= ($status === 'ALL' ? 'selected' : '') ?>>ALL</option>
					<option value="OPEN" <?= ($status === 'OPEN' ? 'selected' : '') ?>>OPEN</option>
					<option value="PENDING" <?= ($status === 'PENDING' ? 'selected' : '') ?>>PENDING</option>
					<option value="DONE" <?= ($status === 'DONE' ? 'selected' : '') ?>>DONE</option>
				</select>
			</label>

			<div class="FORM__FOOT">
				<button type="submit" class="is-ok MT1rm">検索(EnterでもOK)</button>

				<a class="BTN is-ghost" href="<?= h($self) ?>">条件クリア</a>
			</div>

		</form>

		<div class="CARD MT1rm">
			<strong>ヒット件数:</strong><?= h((string)$filteredCount) ?> 件 
			<strong>ページ:</strong><?= h((string)$page) ?> / <?= h((string)$totalPages) ?>
		</div>

		<div class="TABLE_WRAPPER">
		<table class="TABLE">
			<thead>
				<tr>
					<th class="W15pc">ID</th>
					<th>タイトル</th>
					<th class="W15pc">担当</th>
					<th class="W15pc">ステータス</th>
				</tr>
			</thead>
			<tbody>
				<?php if (count($viewRows) === 0): ?>
					<tr>
						<td colspan="4" class="HTMX-NOTE">該当データがありません。</td>
					</tr>
				<?php else: ?>
					<?php foreach ($viewRows as $r): ?>
						<tr>
							<td><?= h((string)$r['id']) ?></td>
							<td><?= h((string)$r['title']) ?></td>
							<td><?= h((string)$r['owner']) ?></td>
							<td><span class="BADGE"><?= h((string)$r['status']) ?></span></td>
						</tr>
					<?php endforeach; ?>
				<?php endif; ?>
			</tbody>
		</table>
		</div>

		<!-- ページング:クリックはhx-push-urlで履歴に積む -->
		<nav class="PAGER" aria-label="ページング">

			<?php
			// 前のページ
			$prev = max(1, $page - 1);
			// 次のページ
			$next = min($totalPages, $page + 1);
			?>

			<?php
			// 前へURLを作る
			$prevQs = http_build_query($baseParams + ['page' => $prev]);
			// 次へURLを作る
			$nextQs = http_build_query($baseParams + ['page' => $next]);
			?>

			<a
				class="PAGER__btn"
				href="<?= h($self . '?' . $prevQs) ?>"
				hx-get="<?= h($self . '?' . $prevQs) ?>"
				hx-target="#URL_APP"
				hx-swap="outerHTML"
				hx-push-url="true"
				<?= ($page <= 1 ? 'aria-disabled="true"' : '') ?>
			>前へ</a>

			<?php for ($p = $startPage; $p <= $endPage; $p++): ?>

				<?php
				// ページURLを作る
				$qs = http_build_query($baseParams + ['page' => $p]);
				// 現在ページ判定
				$isCurrent = ($p === $page);
				?>

				<a
					class="PAGER__num <?= ($isCurrent ? 'is-current' : '') ?>"
					href="<?= h($self . '?' . $qs) ?>"
					hx-get="<?= h($self . '?' . $qs) ?>"
					hx-target="#URL_APP"
					hx-swap="outerHTML"
					hx-push-url="true"
					<?= ($isCurrent ? 'aria-current="page"' : '') ?>
				><?= h((string)$p) ?></a>

			<?php endfor; ?>

			<a
				class="PAGER__btn"
				href="<?= h($self . '?' . $nextQs) ?>"
				hx-get="<?= h($self . '?' . $nextQs) ?>"
				hx-target="#URL_APP"
				hx-swap="outerHTML"
				hx-push-url="true"
				<?= ($page >= $totalPages ? 'aria-disabled="true"' : '') ?>
			>次へ</a>

		</nav>

	</section>

</body>
</html>

デモ

フィルタ/ページングでURLも更新する(共有・戻る進む・再現)

デモを別タブで開く

解説

1つの画面で「共有」「戻る/進む」「URL再現」を同時に実現

  • フィルタ(入力・セレクト変更)で一覧を更新しつつ、URLも最新の条件に更新します。
  • ページングは履歴に積むので、ブラウザの戻る/進むでも状態が自然に戻ります。
  • URLに条件(q, status, page)が入るため、URLだけで状態を再現できます。

フィルタ変更は hx-replace-url(履歴を増やさない)

  • ライブ検索のたびに hx-push-url を使うと、履歴が大量に積まれて「戻れない」状態になります。
  • そこでフィルタ変更は hx-replace-url="true" を使い、URLだけ更新して履歴は増やさないようにします。
  • 結果として「共有できるURL」だけ保ちつつ、戻る/進む体験を壊しません。

ページングは hx-push-url(履歴に積む)

  • ページ移動(前へ/次へ/ページ番号)は“ナビゲーション”なので、hx-push-url="true" で履歴に積みます。
  • これにより、ブラウザの戻る/進むが「ページ移動」と一致して、自然な操作感になります。

差し替えは #URL_APP を丸ごと(フォーム+結果がズレない)

  • 差し替え単位を小さくしすぎると、フォームの値と一覧の表示がズレる事故が起きがちです。
  • このデモでは hx-target="#URL_APP"hx-swap="outerHTML" で、フォーム+結果+ページングをひとまとめで更新します。
  • 戻る/進むでも同じHTML状態に戻るので、ズレにくく安定します。

まとめ:フィルタは replace、ページングは push
URLで状態を持てるようになると、管理画面の一覧検索が一気に“共有できるUI”になります。

※デモでは、便宜的に自作のCSSを使用してます。

このページの著者

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

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

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

得意:PHP・JavaScript・MySQL・CSS

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

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

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

もちもちみかん.comとは


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

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