React Router v6 を使ったパンくずリストの実装パターンを考える

2022.02.06

MAD 事業部の高橋ゆうきです。

以前に React Router を v5 から v6 にアップデートしてみました という記事を書いたのですが、そちらは最小限のアップデート作業に限定した内容でした。

ここでは v5 以前でも可能だった方法も含めた、アップデートによってできるようになったパンくずリストの作成方法について考えてみます。

いくつかの実装パターンを考える

考えられるパンくずリスト生成方法のいくつかの実装パターンを考えてみます。

  1. 個々の Page にそれぞれもつ
  2. Global な State にもつ
  3. パンくずリストが別の Routes をもつ
  4. use-react-router-breadcrumbs を使う
  5. 通常のルートとパンくずリストを同じ関数で生成させ、それぞれで useRoutes() を使う

4 と 5 が v6 以降で可能になった方法です。

サンプルで使用するサイトの構造

サンプルサイトのベースに使うのは以下のコードになります。

極力シンプルな構成にするつもりなのですが、以下のパッケージを使用しています。

ページの構成は以下のようになります。

url / /users /users/:userId
title ホーム ユーザ一覧 ユーザ詳細
breadcrumbs ホーム ホーム > ユーザ一覧 ホーム > ユーザ一覧 > ユーザ名

サンプルサイトの遷移図

また、基本のルーティング部分のコードの抜粋は以下になります。共通の Layout として DefaultLayout があるため、Nested Routes を使用しています。

// ./src/AppRoutes.tsx
export const AppRoutes = (): JSX.Element => {
  return (
    <Routes>
      <Route path="/" element={<DefaultLayout />}>
        <Route index element={<Home />} />
        <Route path="/users" element={<Users />} />
        <Route path="/users/:userId" element={<User />} />
      </Route>
    </Routes>
  );
};

1. 個々の Page にそれぞれもつ

コードは以下の PR で確認できます。

./src/AppRoutes.tsx で行っていた Nested Routes の利用をやめ、すべての Page に DefaultLayout をばら撒きます。その上でDefaultLayout の props に breadcrumbs を追加し配列を渡すだけの単純な方法です。

Layout 側ではパンくず用の配列を受け取り、それを表示するだけになります。パンくずリストの最後に表示される項目には現在ページをリンクなしで表示させたいので、to を optional にしています。

export type Breadcrumbs = {
  title: string;
  to?: string;
}[];

type Props = Readonly<
  PropsWithChildren<{
    breadcrumbs: Breadcrumbs;
  }>
>;

export const DefaultLayout = (props: Props): JSX.Element => {
  const { breadcrumbs, children } = props;

  return (
    <div className={classes.root}>
      {/* 略 */}
      <nav>
        <ol className={classes.breadcrumbs}>
          {breadcrumbs.map((item, i) => (
            <li key={i}>
              {item.to ? <Link to={item.to}>{item.title}</Link> : item.title}
            </li>
          ))}
        </ol>
      </nav>
      <main>{children}</main>
    </div>
  );
};

Page では以下の用に使います。パンくずのタイトルが API のレスポンスに依存する場合にはコンポーネントの外に配列を出すことはできません。

const breadcrumbs: Breadcrumbs = [
  { title: "ホーム", to: "/" },
  { title: "ユーザ一覧" },
];

export const Page = (): JSX.Element => {
  return (
    <DefaultLayout breadcrumbs={breadcrumbs}>
      <h2>Page</h2>
    </DefaultLayout>
  );
};

積極的にこの方法を採用したくはありませんが、実装が単純なので理解しやすそうではあります。

理想は Page がパンくずの情報を持たずに、AppRoutes.tsx あたりで管理できると嬉しいです。

2. Global な State にもつ

コードは以下の PR で確認できます。

先程の例では DefaultLayout にパンくずリストの配列を Props を使って渡していました。この例ではパンくずリストの配列を Context API を使って渡すようにしています。

パンくずリスト用に以下のカスタムフックを作成しています。

// ./src/hooks/useBreadcrumbs.ts
export const useBreadcrumbs = (breadcrumbs: Breadcrumbs) => {
  const setBreadcrumbs = useSetBreadcrumbsContext();

  useEffect(() => {
    setBreadcrumbs(breadcrumbs);

    return () => {
      // MEMO:
      // Pageでパンくずをセットし忘れてしまったときに、
      // 以前の状態を引き継がないように、アンマウント時に空にする
      setBreadcrumbs([]);
    };
  }, [breadcrumbs, setBreadcrumbs]);
};

setBreadcrumbs を忘れてしまうと前回の状態を引き継いだままの誤ったパンくずリストが表示されてしまうため、アンマウント時には空配列をセットしています。

こちらも 1 の方法と同様の問題があり、その問題をより複雑にしてしまっただけのような気もします。

3. パンくずリストが別の Routes をもつ

コードは以下の PR で確認できます。

Layout 用コンポーネントにパンくずリストのための Routes をもたせる方法になります。

// ./src/layouts/DefaultLayout.tsx の一部
<nav>
  <ol className={classes.breadcrumbs}>
    <Routes>
      <Route path="/" element={<li>ホーム</li>} />
      <Route
        path="/*"
        element={
          <>
            <li>
              <Link to="/">ホーム</Link>
            </li>
            <Outlet />
          </>
        }
      >
        <Route path="users" element={<li>ユーザ一覧</li>} />
        <Route
          path="/*"
          element={
            <>
              <li>
                <Link to="/users">ユーザ一覧</Link>
              </li>
              <Outlet />
            </>
          }
        >
          <Route
            path="users/:userId"
            element={
              <li>
                <Username />
              </li>
            }
          />
        </Route>
      </Route>
    </Routes>
  </ol>
</nav>

v6 からは Nested Routes と Outlet で手間なく作成できるようにはなっています。

1, 2 にあった問題は解決しましたが、2 箇所でルーティングを管理していることが辛いことに加え、ネストが多くなりがちで最終的なパスがどうなるのかもわかりずらく、積極的にこの方法をとる理由がないように思えました。

Nested Routes と Outlet を使わない方法もあります。

<nav>
  <ol className={classes.breadcrumbs}>
    <Routes>
      <Route path="/" element={<li>ホーム</li>} />
      <Route
        path="/users"
        element={
          <>
            <li>
              <Link to="/">ホーム</Link>
            </li>
            <li>ユーザ一覧</li>
          </>
        }
      />
      <Route
        path="users/:userId"
        element={
          <>
            <li>
              <Link to="/">ホーム</Link>
            </li>
            <li>
              <Link to="/users">ユーザ一覧</Link>
            </li>
            <li>
              <Username />
            </li>
          </>
        }
      />
    </Routes>
  </ol>
</nav>

どちらがいいのかは微妙なところです。サンプルのようにこのくらい小さな規模であれば後者のほうがわかりやすいですが、少し規模が大きくなってくると面倒そうです。

4. use-react-router-breadcrumbs を使う

コードは以下の PR で確認できます。

use-react-router-breadcrumbs は url からパンくずリストを生成するライブラリになります。

このライブラリの useBreadcrumbs() と React Router v6 から使用できるようになった useRoutes() は非常に相性がいいです。

export const routes: Parameters<typeof useBreadcrumbs>[0] = [
  {
    path: "/",
    element: <DefaultLayout />,
    children: [
      {
        index: true,
        element: <Home />,
        breadcrumb: "ホーム",
      },
      {
        path: "/users",
        element: <Users />,
        breadcrumb: "ユーザ一覧",
      },
      {
        path: "/users/:userId",
        element: <User />,
        breadcrumb: BreadcrumbsUser,
      },
    ],
  },
];

export const AppRoutes = (): JSX.Element => {
  // 以下のように使う
  const element = useRoutes(routes);
  const breadcrumbs = useBreadcrumbs(routes);
};

これは use-react-router-breadcrumbs の README でも、Route object compatibility として紹介されている方法です。

ルートとパンくずを 1 つの設定で管理できるため、これまでの方法の問題を解決できました。

5. 通常のルートとパンくずリストを同じ関数で生成させ、それぞれで useRoutes() を使う

コードは以下の PR で確認できます。

これは react-router の Issue のコメント にあった方法を参考にしたものです。ただ今回のサイトのように、Nested Routes と Layout を使っていた場合にはちょっと工夫が必要になります。

以下が Page とパンくずのルートを生成する関数とパンくずリストを生成するためのコンポーネントです。

// ./src/utils/createRoutes.tsx
export const createRoutes = (
  isPage = true
): Parameters<typeof useRoutes>[0] => [
  {
    path: "*",
    element: isPage ? <DefaultLayout /> : <Breadcrumbs text="ホーム" />,
    children: [
      {
        index: true,
        element: isPage ? <Home /> : <Outlet />,
      },
      {
        path: "users",
        element: isPage ? <Users /> : <Breadcrumbs text="ユーザ一覧" />,
        children: [
          {
            path: ":userId",
            element: isPage ? <User /> : <BreadcrumbsUser />,
          },
        ],
      },
    ],
  },
];
// ./src/components/Breadcrumbs/Breadcrumbs.tsx
export const Breadcrumbs = (props: Props) => {
  const { text } = props;

  const resolvedLocation = useResolvedPath("");

  // このページがアクティブなのかどうかを判定するカスタムフック
  const isActive = useIsActive();

  return (
    <>
      {isActive ? (
        <li>{text}</li>
      ) : (
        <li>
          <NavLink to={resolvedLocation.pathname}>{text}</NavLink>
        </li>
      )}
      <Outlet />
    </>
  );
};

createRoutes() における index: true のルートのパンくずですが、ここで以下のようにしてしまうと children にあるパスでそのパンくずが表示されなくなってしまうので注意が必要です。

// ./src/utils/createRoutes.tsx
export const createRoutes = (
  isPage = true
): Parameters<typeof useRoutes>[0] => [
  {
    path: "*",
    element: isPage ? <DefaultLayout /> : <Outlet />,
    children: [
      {
        index: true,
        element: isPage ? <Home /> : <Breadcrumbs text="ホーム" />,
      },
      {
        path: "users",
        // ここでは <Breadcrumbs text="ホーム" /> が経由されなくなってしまう
      },
    ],
  },
];

また、ネストされたルートが親のコンテンツを表示しない場合には、Page 側でも注意が必要になります。今回ではユーザ一覧がそれに該当し、ページがアクティブではないときにはこのコンテンツのみを表示させるように Outlet のみを戻しています。

// ./src/pages/Users/Users.tsx
export const Users = (): JSX.Element => {
  const { data } = useAspidaSWR(client.users, "get");
  const isActive = useIsActive();

  // アクティブではない場合には子のコンテンツのみを表示させる
  if (!isActive) {
    return <Outlet />;
  }

  return <>...</>
  );
};

この方法を見つけたときには良さそうと思ったのですが、パンくずを生成するためのルートを記述することが強要されてしまいます。仮に階層が深いパンくずの階層自体を変えるようなときにはパスだけではない部分でもそれなりの改修が必要になりそうです。

パンくずの階層の深さが、Nested Routes の深さとイコールにになってしまうのも少し気がかりです。ネストが深くなるとルーティングが扱いにくくなるという話は Rails ガイドにも書かれているように避けたいと考えているからです。

まとめ

基本的には 4 であげた use-react-router-breadcrumbs を使う方法がよさそうです。5 もテクニカルな感じがして悪くないように見えるのですが、保守を考えるとこれをメンテナンスしたくはないので積極的に採用する意味はなさそうです。

仮に 4 の方法を使えないとするのであれば、結局は愚直に 1 で書くのが素直でわかりやすそうかなと感じます。