React Routerで、絞り込みの条件をURLに持たせる方法
一覧の絞り込みの条件を URL のクエリ(?color=white&sort=age の部分)に置くと、ページを再読み込みしても、URL を人に送っても、同じ条件の一覧が表示されます。ブラウザの「戻る」で、ひとつ前の条件にも戻れます。React Router では、useSearchParams でクエリを読み書きすれば、条件を useState に写さずに済みます。
筆者は検証環境で、猫の一覧を名前・毛の色・並び順で絞り込む画面を作り、ブラウザで操作して確かめました。その中で、検索欄の履歴の残し方、入力欄のカーソルが末尾に飛ぶ問題、setSearchParams を続けて呼んだときの結果の3つに、気を付ける必要があると分かりました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 141 です。使ったのは react-router 8.5.0、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2 です。esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作しました。ルーターは宣言型の BrowserRouter で、確かめたのはブラウザだけで動く画面です。データモード(createBrowserRouter)や、そこでのローダーとの組み合わせ、サーバーでの描画は試していません。
条件は、描画のたびに URL のクエリから読む
useSearchParams のリファレンスにあるとおり、useSearchParams は、今の URL のクエリを表す URLSearchParams と、それを書き換える関数の組を返します。条件は、この searchParams から描画のたびに読みます。useState に入れ直さないので、URL と画面の状態がずれることがありません。React Router の状態の管理についての説明でも、URL に置ける状態を React の状態と URL の両方に持って同期させる書き方を、避けたい例として挙げています。
読む処理は、関数にまとめておきます。URL は利用者が手で書き換えられるので、知らない値が来ても困らないようにします。
import { COLORS, type Color } from './cats';
export type Sort = 'name' | 'age';
const isColor = (v: string): v is Color => (COLORS as readonly string[]).includes(v);
// URL のクエリから絞り込みの条件を読む。知らない値は無視して、既定の値にする
export function readFilters(searchParams: URLSearchParams) {
const q = searchParams.get('q') ?? '';
const colors = searchParams.getAll('color').filter(isColor);
const sort: Sort = searchParams.get('sort') === 'age' ? 'age' : 'name';
return { q, colors, sort };
}
毛の色のように複数を選べる条件は、?color=white&color=orange のように同じ名前を並べ、getAll で配列として読みます。MDN の getAll の説明のとおり、値がなければ空の配列が返ります。
コンポーネントでは、読んだ条件で一覧を絞り込むだけです。
import { useSearchParams } from 'react-router';
import { CATS, COLORS, COLOR_LABELS, type Color } from './cats';
import { readFilters, type Sort } from './filters';
export function CatList() {
const [searchParams, setSearchParams] = useSearchParams();
const { q, colors, sort } = readFilters(searchParams);
const visible = CATS.filter(
(cat) => cat.name.includes(q) && (colors.length === 0 || colors.includes(cat.color)),
).sort((a, b) => (sort === 'age' ? a.age - b.age : a.name.localeCompare(b.name, 'ja')));
// toggleColor・changeSort と画面の部分は次の節で、changeQuery は検索欄の履歴の節で書きます
}
検証で /cats?color=white&color=orange&sort=age を直接開くと、「白」と「茶トラ」にチェックが入り、並び順は「年齢」になって、4匹が若い順に並びました。

/cats?sort=zzz&color=purple&color=black のように、知らない値を混ぜた URL も開いてみました。並び順は既定の「名前」になり、purple は無視されて、黒の2匹だけが表示されました。URL は書き換わらず、開いたときのままでした。
条件を変えるときは、setSearchParams で URL を書き換える
条件を変えるときは、setSearchParams で URL を書き換えます。useSearchParams のリファレンスによると、setSearchParams を呼ぶと、新しい URL への移動(ナビゲーション)が起きます。useState の更新関数と同じように、今のクエリを受け取って新しいクエリを返す関数も渡せます。
function toggleColor(color: Color, checked: boolean) {
setSearchParams((prev) => {
const next = COLORS.filter((c) => (c === color ? checked : colors.includes(c)));
prev.delete('color');
for (const c of next) prev.append('color', c);
return prev;
});
}
function changeSort(value: Sort) {
setSearchParams((prev) => {
if (value === 'name') prev.delete('sort');
else prev.set('sort', value);
return prev;
});
}
既定の値(並び順の「名前」)を選んだときは、sort をクエリから消しています。こうすると、条件を付けていない画面の URL が /cats のまま短く保たれます。毛の色は、いつも COLORS の順に並べ直してから入れているので、チェックした順番によって URL が変わることはありません。
検証で「黒」「白」の順にチェックしても、URL は ?color=white&color=black になり、「白」「黒」の順にチェックしたときと同じでした。並び順で「年齢」を選んでから「名前」に戻すと、URL から sort が消えました。
関数に渡される prev は、useSearchParams が返した searchParams そのものではありません。インストールした react-router 8.5.0 のソースでは、new URLSearchParams(searchParams) で作った写しを渡しているので、prev を書き換えても、描画中の searchParams は変わりません。
画面の部分では、入力欄の値に、どれも URL から読んだ条件を使います。「条件をクリア」は、空のオブジェクトを渡してクエリを全部消します。
return (
<main>
<label>
名前で探す
<input type="search" value={q} onChange={(e) => changeQuery(e.target.value)} />
</label>
<fieldset>
<legend>毛の色</legend>
{COLORS.map((c) => (
<label key={c}>
<input
type="checkbox"
checked={colors.includes(c)}
onChange={(e) => toggleColor(c, e.target.checked)}
/>
{COLOR_LABELS[c]}
</label>
))}
</fieldset>
<label>
並び順
<select value={sort} onChange={(e) => changeSort(e.target.value as Sort)}>
<option value="name">名前</option>
<option value="age">年齢</option>
</select>
</label>
<button type="button" onClick={() => setSearchParams({})}>
条件をクリア
</button>
{/* 一覧の表示は省略 */}
</main>
);
検証では、/cats を開いて「白」「黒」にチェックし、並び順で「年齢」を選びました。/cats を開いた時点の history.length は、検証環境では 2 でした。以下では、そこから増えた数で比べます。URL は操作のたびに ?color=white、?color=white&color=black、?color=white&color=black&sort=age と変わり、history.length は 2 から 5 まで1つずつ増えました。ブラウザの「戻る」を押すと並び順が「名前」に、もう一度押すと「黒」のチェックが外れた状態に戻りました。「進む」で「白」「黒」の状態に戻り、そこで再読み込みしても条件はそのままでした。「条件をクリア」を押すと、URL は /cats になり、7匹がすべて表示されました。
検索欄は、1文字ごとに履歴を残さない
インストールした react-router 8.5.0 の型定義では、setSearchParams の第2引数の型は NavigateOptions です。ソースでも、受け取った値をそのまま navigate に渡していました。つまり、useNavigate と同じ移動のオプションを渡せます。useNavigate のリファレンスにあるとおり、replace: true を付けると、履歴に新しい項目を足さずに、今の項目を置き換えます。
検索欄の changeQuery で replace を指定しないと、1文字入力するたびに履歴が1つ増えます。そこで、changeQuery の書き方を3通り作り、「白」にチェックしてから「しろ」と1文字ずつ入力し、「戻る」を1回押して比べました。
changeQuery の書き方 |
history.length(入力前 → 入力後) |
「戻る」を1回押した後 |
|---|---|---|
replace を指定しない |
3 → 5 | 検索欄が「し」になる |
いつも replace: true |
3 → 3 | /cats に戻り、白のチェックも外れる |
| 最初の1文字だけ追加し、あとは置き換える | 3 → 4 | 検索欄が空になり、白のチェックは残る |

いつも replace: true にすると、入力を始める前の項目(白にチェックした状態)まで置き換わり、「戻る」で白のチェックも消えてしまいました。筆者は、URL のクエリにまだ q がないとき(入力を始めた最初の1文字)だけ履歴に足し、そのあとは置き換える書き方にしました。
function changeQuery(value: string) {
setSearchParams(
(prev) => {
if (value === '') prev.delete('q');
else prev.set('q', value);
return prev;
},
{ replace: searchParams.has('q') },
);
}
検証のコードでは、3通りをビルドのときに切り替えられるようにしてあり、上のコードは「最初の1文字だけ追加する」場合の形に書き直したものです。
入力欄のカーソルが末尾に飛ぶときは、useTransitions を false にする
ここまでの検証は、BrowserRouter に useTransitions={false} を付けて行いました。付けない(既定の)ままだと、入力欄の動きがおかしくなったからです。
既定のまま、/cats?q=こむぎ を開き、検索欄の先頭にカーソルを置いて「お」を入力しました。検索欄は「おこむぎ」になりましたが、カーソルの位置(selectionStart)は 1 ではなく 4、つまり末尾に移っていました。続けて「ば」を入力すると、「おばこむぎ」ではなく「おこむぎば」になりました。チェックボックスでは、クリックした直後に URL はもう ?color=white になっていたのに、checked は false のままでした。setTimeout(0) の後も false で、true になったのは、100 ミリ秒後に読んだときでした。URL が先に変わり、チェックの表示はあとから切り替わったことになります。
useTransitions={false} を付けると、カーソルの位置は 1 のままで、続けて入力した結果は「おばこむぎ」になりました。チェックボックスも、クリックした直後から true でした。
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Route, Routes } from 'react-router';
import { CatList } from './CatList';
createRoot(document.getElementById('root')!).render(
<BrowserRouter useTransitions={false}>
<Routes>
<Route path="/cats" element={<CatList />} />
</Routes>
</BrowserRouter>,
);
検証のコードでは、useTransitions に渡す値をビルドのときに切り替え、今の URL を画面に表示する部分と、次の節で使うページのルートも入れています。
BrowserRouter のリファレンスによると、useTransitions を指定しないと、ルーターの状態の更新はすべて React.startTransition で包まれます。false にすると、startTransition を使いません。インストールした 8.5.0 のソースでも、useTransitions === false のときだけ、そのまま状態を更新していました。
React の useTransition のリファレンスには、入力欄の値を決める状態の更新は Transition にできない、と書かれています。入力の変化に応じた更新は同期的に行う必要があるからです。URL のクエリを入力欄の値にすると、useTransitions が既定のままでは、まさにこの形になります。同じ節には、入力欄用に同期的に更新する状態を別に持つ方法や、useDeferredValue を使う方法も載っていますが、筆者はこの画面では試していません。
useTransitions={false} は、ルーター全体の動きを変えます。React Router の Transition についての説明では、移動の途中で Suspense の代わりの表示(fallback)を出さずに済ませるなど、Transition を使う利点にも触れています。筆者が確かめたのは、この記事の画面での入力欄とチェックボックスの動きだけです。
1回の操作で、setSearchParams を2回呼ばない
setSearchParams に関数を渡す形は useState の更新関数に似ていますが、続けて呼んだときの結果が違います。1回のクリックで、white を足す呼び出しと black を足す呼び出しを続けて行うボタンを作って試しました。
function selectTwiceSeparately() {
setSearchParams((prev) => {
prev.append('color', 'white');
return prev;
});
setSearchParams((prev) => {
prev.append('color', 'black');
return prev;
});
}
押した後の URL は ?color=black で、white は残りませんでした。history.length は 2 から 4 に増え、移動が2回起きていました。2つの変更を1つの関数の中で行い、setSearchParams を1回だけ呼ぶと、URL は ?color=white&color=black になりました。
useSearchParams のリファレンスにも、関数を渡す形は setState のような順番待ち(キュー)の仕組みを持たないので、同じタイミングで何回も呼ぶと、前の呼び出しの結果を元にしない、と注意書きがあります。8.5.0 のソースでは、どちらの呼び出しも、描画したときの searchParams の写しを prev として渡していました。
条件は searchParams から毎回読み、変えるときは1回の操作につき setSearchParams を1回だけ呼びます。検索欄は入力中の履歴を置き換え、入力欄を URL の値で動かすなら、useTransitions={false} を付けたうえで、カーソルの動きを確かめます。





