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を使用してます。
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール