React Routerで、パンくずリストを作る方法

​​React​Playwright,React Router,TypeScript,アクセシビリティ

React Router のデータモードでは、ルートごとに handle へパンくずリストの項目名を書いておき、useMatches でいま表示しているルートを外側から順に集めれば、パンくずリストができます。URL を自分で / で区切って名前を引き当てる必要はありません。猫の名前のように loader で読み込む値も、項目名に使えます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、React Router 8.5.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作しました。React Router のフレームワークモード(routes.ts とルートモジュールで書く形)、サーバーでの描画、Firefox と Safari、実際のスクリーンリーダーでは試していません。

猫の一覧で「たま」をクリックすると、少し待ってからパンくずリストが「ホーム › 猫の一覧 › たま」になる。「ごはんの記録を見る」をクリックすると「ホーム › 猫の一覧 › たま › ごはんの記録」になり、パンくずの「猫の一覧」をクリックすると「ホーム › 猫の一覧」に戻る
3回のクリックで、パンくずリストの項目が増えたり減ったりする動き。新しく現在のページになった項目は、その瞬間だけピンクになる(上の帯、カーソル、クリックの輪、ピンクは GIF 用に重ねたもの。画面を撮るのに時間がかかるため、待ち時間は実際より長く見える)

ルートの handle に項目名を書く

検証では、次の4つのページを持つアプリを作りました。

  • /:ホーム
  • /cats:猫の一覧
  • /cats/1:猫のページ(名前は loader で読み込む)
  • /cats/1/meals:その猫のごはんの記録

React Router のルートオブジェクトの説明によると、handle には何を入れてもよく、入れたものは useMatches で受け取れます。パンくずリストはその使い道の例として挙げられています。ここでは、項目名を返す関数を breadcrumb という名前で入れることにしました。

tsx
export const routes: RouteObject[] = [
  {
    path: '/',
    element: <Layout />,
    handle: breadcrumb(() => 'ホーム'),
    children: [
      { index: true, element: <Link to="cats">猫の一覧へ</Link> },
      {
        path: 'cats',
        handle: breadcrumb(() => '猫の一覧'),
        children: [
          { index: true, loader: catsLoader, element: <CatList /> },
          {
            id: 'cat',
            path: ':catId',
            loader: catLoader,
            handle: breadcrumb<Cat>((cat) => cat?.name ?? '見つからない猫'),
            errorElement: <CatError />,
            children: [
              { index: true, element: <CatPage /> },
              { path: 'meals', handle: breadcrumb(() => 'ごはんの記録'), element: <Meals /> },
            ],
          },
        ],
      },
    ],
  },
];

:catId のルートでは、catLoader が返した猫のデータから名前を取り出しています。catLoader は、見つからない猫の id のときに404のエラーを投げます。

tsx
async function catLoader({ params }: LoaderFunctionArgs) {
  const cat = await getCat(params.catId ?? '');
  if (!cat) throw data('猫が見つかりません', { status: 404 });
  return cat;
}

useMatches で集めて、nav と ol で並べる

パンくずリストを表示するコンポーネントは Breadcrumbs です。handle に入れる値を作る breadcrumb 関数も、同じファイルに置きました。

tsx
import { Link, useMatches, type UIMatch } from 'react-router';

export type BreadcrumbHandle = {
  breadcrumb: (match: UIMatch) => string;
};

// ルートの handle に置く。data は、そのルートの loader が返した値
export function breadcrumb<Data = unknown>(label: (data: Data | undefined) => string): BreadcrumbHandle {
  return { breadcrumb: (match) => label(match.loaderData as Data | undefined) };
}

function hasBreadcrumb(match: UIMatch): match is UIMatch<unknown, BreadcrumbHandle> {
  const handle = match.handle as Partial<BreadcrumbHandle> | undefined;
  return typeof handle?.breadcrumb === 'function';
}

export function Breadcrumbs() {
  const matches = useMatches().filter(hasBreadcrumb);

  return (
    <nav aria-label="パンくずリスト" className="breadcrumbs">
      <ol>
        {matches.map((match, i) => {
          const label = match.handle.breadcrumb(match);
          const isCurrent = i === matches.length - 1;
          return (
            <li key={match.id}>
              {isCurrent ? (
                <span aria-current="page">{label}</span>
              ) : (
                <Link to={match.pathname}>{label}</Link>
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}

useMatches の戻り値の handle と loaderData は、型が unknown です。そのため、hasBreadcrumb で breadcrumb 関数を持つものだけに絞り、型もそこで付けています。ルートの側で breadcrumb<Cat>(...) と書くと、項目名を作る関数が受け取る値は Cat | undefined になります。ただし、as で型を決めているので、loader が返す型と <Cat> の食い違いは型チェックで見つかりません。検証環境で、{ id, name } を返す loader のルートに breadcrumb<{ title: string }>(...) と書いても、tsc はエラーを出しませんでした(そのファイルが型チェックの対象に入っていることも確かめています)。

各項目のリンク先には、match.pathname(そのルートが一致した URL の部分)を使います。最後の項目はいま表示しているページなので、リンクにせず、aria-current="page" を付けました。WAI-ARIA Authoring Practices Guide(APG)にある、パンくずリストのパターンでは、パンくずリストを aria-label などで名前を付けた navigation のランドマークに入れ、現在のページへのリンクには aria-current="page" を付けるとされています。現在のページがリンクでないときの aria-current は、任意です。

Breadcrumbs は、いちばん上のルートの Layout に置きます。

tsx
export function Layout() {
  return (
    <>
      <header>
        <Breadcrumbs />
      </header>
      <main>
        <Outlet />
      </main>
    </>
  );
}

検証環境(Ubuntu 24.04、Chromium 153)で、それぞれの URL を開いたときの useMatches の中身と、表示されたパンくずリストは次のとおりです。

text
## /cats/1/meals
  id=0 pathname=/ breadcrumb=true loaderData=undefined
  id=0-1 pathname=/cats breadcrumb=true loaderData=undefined
  id=cat pathname=/cats/1 breadcrumb=true loaderData={"id":"1","name":"たま"}
  id=0-1-1-1 pathname=/cats/1/meals breadcrumb=true loaderData=undefined
  パンくず: ホーム(/) › 猫の一覧(/cats) › たま(/cats/1) › ごはんの記録[aria-current=page]

useMatches は、外側のルートから順に並びます。:catId のルートの loaderData には、catLoader が返した猫のデータが入っていて、項目名は「たま」になりました。なお、useMatches のリファレンスにあるとおり、useMatches は createBrowserRouter などのデータルーターの中でしか使えません。

項目名は index ルートではなく、親のルートに書く

/cats を開くと、一致するルートは cats のルートと、その index ルートの2つでした。

text
## /cats
  id=0 pathname=/ breadcrumb=true loaderData=undefined
  id=0-1 pathname=/cats breadcrumb=true loaderData=undefined
  id=0-1-0 pathname=/cats/ breadcrumb=false loaderData=[{"id":"1","name":"たま"},{"id":"2","name":"みけ"}]
  パンくず: ホーム(/) › 猫の一覧[aria-current=page]

index ルートの pathname は /cats/ で、末尾に / が付いています。検証で index ルートにも同じ handle を書いたところ、「猫の一覧」が2つ並びました。そのため、項目名は子を持つ側の cats のルートにだけ書きました。cats のルートは、/cats を開いても /cats/1 を開いても一致するので、どちらでも「猫の一覧」の項目が出ます。

猫のページも同じ形にしています。:catId のルートに loader と handle を置き、画面は index ルートの CatPage が描画します。loader を持っているのは親の :catId のルートなので、CatPage では useRouteLoaderData にそのルートの id('cat')を渡して、猫のデータを受け取ります。

tsx
// loader は親の :catId のルートにあるので、ルートの id を指定して受け取る
function CatPage() {
  const cat = useRouteLoaderData<typeof catLoader>('cat')!;
  return (
    <>
      <h1>{cat.name}のページ</h1>
      <Link to="meals">ごはんの記録を見る</Link>
    </>
  );
}

移動中とエラーのときの表示

猫の一覧で「たま」をクリックしてから、パンくずリストがどう変わるかを測りました。loader が呼ぶ getCat は、サーバーへの問い合わせの代わりに、300ミリ秒待ってから返します。

text
   51ミリ秒後 URL=/cats パンくず: ホーム(/) › 猫の一覧[aria-current=page]
  151ミリ秒後 URL=/cats パンくず: ホーム(/) › 猫の一覧[aria-current=page]
  250ミリ秒後 URL=/cats パンくず: ホーム(/) › 猫の一覧[aria-current=page]
  401ミリ秒後 URL=/cats/1 パンくず: ホーム(/) › 猫の一覧(/cats) › たま[aria-current=page]

250ミリ秒後までは、URL もパンくずリストも前のページのままでした。401ミリ秒後には、URL もパンくずリストも新しいページのものになっていました。React Router の Pending UI のガイドにあるとおり、React Router は次のページの loader が終わるのを待ってから、そのページを描画します。そのため、パンくずリストの項目名を作るときには、新しいページの loader のデータがそろっています。読み込み中であることを見せたいときは、パンくずリストとは別に、同じガイドにある useNavigation で移動中かどうかを取り出して表示します。データモードの Pending UI のページには、フレームワークモードと同じだと書かれています。

存在しない /cats/999 を開くと、catLoader が404を投げ、:catId のルートの errorElement が描画されました。

text
## /cats/999
  id=0 pathname=/ breadcrumb=true loaderData=undefined
  id=0-1 pathname=/cats breadcrumb=true loaderData=undefined
  id=cat pathname=/cats/999 breadcrumb=true loaderData=undefined
  id=0-1-1-0 pathname=/cats/999/ breadcrumb=false loaderData=undefined
  パンくず: ホーム(/) › 猫の一覧(/cats) › 見つからない猫[aria-current=page]
  エラー表示: 404:猫が見つかりません

:catId のルートは一致したままですが、loaderData は undefined です。項目名を作る関数が cat?.name ?? '見つからない猫' のように undefined を受け止めているので、パンくずリストはそのまま描画され、「ホーム」と「猫の一覧」のリンクから戻れます。

ポイント

loader のデータを項目名に使うときは、データが undefined の場合の名前も決めておきます。loader が投げたエラーを、この記事の :catId のルートのように Layout より下のルートの errorElement で受け止めていれば、パンくずリストは描画されたままになります。

区切りの「›」は、アクセシビリティツリーから外す

項目の間の「›」は、li の ::before に CSS で入れました。MDN の content プロパティのページにあるとおり、content ではスラッシュの後に代替テキストを書けます。代替テキストを空の文字列にしておくと、検証環境の Chromium では、「›」は画面に出ましたが、アクセシビリティツリーには入りませんでした。

css
.breadcrumbs ol { display: flex; flex-wrap: wrap; gap: 4px; list-style: none; margin: 0; padding: 0; }
.breadcrumbs li + li::before { content: "›" / ""; margin-right: 4px; color: #9aa5ab; }

Playwright の ariaSnapshot で、アクセシビリティツリーを書き出して比べました。代替テキストを空にした場合は、次のとおりです。

text
- navigation "パンくずリスト":
  - list:
    - listitem:
      - link "ホーム":
        - /url: /
    - listitem:
      - link "猫の一覧":
        - /url: /cats
    - listitem:
      - link "たま":
        - /url: /cats/1
    - listitem: ごはんの記録

content: "›" だけにした場合は、「›」が文字として入りました(2つ目の項目から後の部分だけを抜き出しています)。

text
    - listitem:
      - text: ›
      - link "猫の一覧":
        - /url: /cats
    - listitem:
      - text: ›
      - link "たま":
        - /url: /cats/1
    - listitem: ›ごはんの記録

最後の項目は「›ごはんの記録」と、区切りの記号と名前がつながっています。項目の区切りは、list と listitem の構造で伝わります。見た目のための記号は CSS で入れ、代替テキストを空にして、アクセシビリティツリーに入れないようにしておきます。

​Posted by 86