React Routerで、絞り込みの条件をURLに持たせる方法

​​React​React Router,TypeScript

一覧の絞り込みの条件を 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 です。ルーターは宣言型の BrowserRouter で、データモード(createBrowserRouter)は試していません。esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作しました。

条件は、描画のたびに URL のクエリから読む

useSearchParams のリファレンスにあるとおり、useSearchParams は、今の URL のクエリを表す URLSearchParams と、それを書き換える関数の組を返します。条件は、この searchParams から描画のたびに読みます。useState に入れ直さないので、URL と画面の状態がずれることがありません。React Router の状態の管理についての説明でも、URL に置ける状態を React の状態と URL の両方に持って同期させる書き方を、避けたい例として挙げています。

読む処理は、関数にまとめておきます。URL は利用者が手で書き換えられるので、知らない値が来ても困らないようにします。

ts
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 の説明のとおり、値がなければ空の配列が返ります。

コンポーネントでは、読んだ条件で一覧を絞り込むだけです。

tsx
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匹が若い順に並びました。

絞り込みの画面。上に URL、その下に検索欄、毛の色のチェックボックス(白と茶トラにチェック)、並び順(年齢)、4匹の一覧が並んでいる
/cats?color=white&color=orange&sort=age を直接開いた画面。いちばん上は、検証用に今の URL を表示した行

/cats?sort=zzz&color=purple&color=black のように、知らない値を混ぜた URL も開いてみました。並び順は既定の「名前」になり、purple は無視されて、黒の2匹だけが表示されました。URL は書き換わらず、開いたときのままでした。

条件を変えるときは、setSearchParams で URL を書き換える

条件を変えるときは、setSearchParams で URL を書き換えます。useSearchParams のリファレンスによると、setSearchParams を呼ぶと、新しい URL への移動(ナビゲーション)が起きます。useState の更新関数と同じように、今のクエリを受け取って新しいクエリを返す関数も渡せます。

tsx

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 から読んだ条件を使います。「条件をクリア」は、空のオブジェクトを渡してクエリを全部消します。

tsx

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 検索欄が空になり、白のチェックは残る
3通りの書き方で、履歴に残る項目を並べた図。replace を指定しないと4項目、いつも置き換えると2項目、最初の1文字だけ追加すると3項目になる
「白」にチェックしてから「しろ」と入力したときに、履歴に残る項目と、「戻る」で移る先

いつも replace: true にすると、入力を始める前の項目(白にチェックした状態)まで置き換わり、「戻る」で白のチェックも消えてしまいました。筆者は、URL のクエリにまだ q がないとき(入力を始めた最初の1文字)だけ履歴に足し、そのあとは置き換える書き方にしました。

tsx

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 でした。

tsx
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 を足す呼び出しを続けて行うボタンを作って試しました。

tsx

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} を付けたうえで、カーソルの動きを確かめます。

この記事で確かめたのは、宣言型の BrowserRouter でブラウザだけで動く画面です。データモードでのローダーとの組み合わせや、サーバーでの描画は試していません。

​Posted by 86