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 です。esbuild で変換したコード(開発用のビルド)を Chromium で動かし、Playwright で操作しました。ルーターは宣言型の BrowserRouter で、確かめたのはブラウザだけで動く画面です。データモード(createBrowserRouter)や、そこでのローダーとの組み合わせ、サーバーでの描画は試していません。

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

​Posted by 86