React Router で可変長のパスを表現したい

## :writing_hand: はじめに

### React Router について

`React Router` はReactアプリケーション内のルーティングを管理するためのライブラリです。（多分React のルーティングライブラリで一番有名です）

シングルページのアプリケーションにURLとコンポーネントを紐づけ従来の多ページWebサイトのような体験を実現させてくれます。

`React Router` で紐づけられるURLは静的なURLだけに留まらず、動的なURLとも対応させることが可能です。

<details open><summary>静的なURLの場合</summary>

`/user/123`にアクセスできます。
```jsx
const routes: RouteObject[] = [
    {
        path: '/user/123',
        element: <Components />
    }
]

const router = createHashRouter([...routes]);

return <RouterProvider router={router} />;
```
</details>

<details open><summary>動的なURLの場合</summary>
    
`/user/123`にも`/user/456`アクセスできます。

```jsx

const routes: RouteObject[] = [
    {
        path: '/user/:id',
        element: <Components />
    }
]

const router = createHashRouter([...routes]);

return <RouterProvider router={router} />;
```

`:id`部分は`React Router` が提供するhooks を用いて参照することができます。

```jsx
import { useParams } from 'react-router-dom';

const Components = () => {
    const { id } = useParams();

    console.log(id); // => /user/123の場合、123
}
```
</details>

### 可変長のパス

一部のパス部分のみが動的なURLの場合はあまり考えることがありません。

今回表現したい可変長のパスは存在しうるパターンの数だけURLのパターン増えていく為、実装方法を考える余地があります。

この記事では可変長のパスの例として、ツリー構造（ディレクトリ構造）をURLで表現してみようと思います。

## :robot: 実装

### TL;DR

- `*`で全マッチング、`useParams` でツリー部分のパスを受け取りパースして自前で探索処理を行う

### 実装

<details open><summary>ツリー構造のオブジェクトを用意</summary>
    
```jsx
export const tree = [
  {
    name: "child1",
    type: "folder",
    children: [
      {
        name: "child1-1",
        type: "folder",
        children: [
          {
            name: "child1-1-1.mp3",
            type: "file",
          },
        ],
      },
      {
        name: "child1-2",
        type: "folder",
        children: [],
      },
    ],
  },
  {
    name: "child2",
    type: "folder",
    children: [
      {
        name: "child2-1",
        type: "folder",
        children: [
          {
            name: "child2-1-1",
            type: "folder",
            children: [
              {
                name: "child2-1-1-1.mp3",
                type: "file",
              },
            ],
          },
        ],
      },
    ],
  },
];
```
</details>

<details open><summary>ツリー部分のパスを全マッチングするようなルートオブジェクトを用意</summary>

```jsx
export const routes = [
  {
    path: "tree",
    element: <Outlet />,
    children: [
      {
        index: true,
        element: <RootPage />,
      },
      {
        path: "*",
        element: <TreePage />,
      },
    ],
  },
  {
    path: "*",
    element: <NotfoundPage />,
  },
];
```

```jsx
export const AppRoutes = () => {
    const router = createBrowserRouter([...routes]);
    
    return <RouterProvider router={router} />;
};
```
</details>

<details open><summary>ツリー構造を表示するコンポーネント作成</summary>

```jsx
import { Link, useParams } from "react-router-dom";

const basePath = "/tree";

export const TreeView = ({ object, parentPath }) => {
  const { "*": matchPath = "" } = useParams();
  // 末尾が'/'の場合は削除
  const pathname = parentPath
    ? parentPath.replace(/\/$/, "")
    : `${basePath}/${matchPath}`.replace(/\/$/, "");

  return (
    <ul>
      {object.map((item, index) => {
        const path = `${pathname}/${item.name}`;

        return (
          <li key={index}>
            <Link to={path}>
              {item.name}
            </Link>
            {item.type === "folder" && item.children && (
              <TreeView object={item.children} parentPath={path} />
            )}
          </li>
        );
      })}
    </ul>
  );
};
```
</details>

<details open><summary>現在のパスのオブジェクトを取得するhooks を用意</summary>

```jsx
export const useFileTree = (tree, name) => {
  // treeObjectからnameに一致するオブジェクトを取得する
  const findObject = (tree, name) => {
    for (const object of tree) {
      if (object.name === name) {
        return object;
      }
      if (object.type === "folder" && object.children) {
        const found = findObject(object.children, name);
        if (found) {
          return found;
        }
      }
    }
    return undefined;
  };

  const targetTree = findObject(tree, name);
  const fileInfo = targetTree
    ? targetTree
    : { name: "Not Found", type: "file" };

  return { targetTree, fileInfo };
};
```
</details>

<details open><summary>ページで表示する</summary>

```tsx
import { useParams } from "react-router-dom";

import { useFileTree } from "~/hooks/useFileTree";
import { tree } from "~/utils/tree";
import { TreeView } from "~/components/Tree/TreeView";

const basePath = "/tree";

export const TreePage = () => {
  const { "*": matchPath = "" } = useParams();
  const pathname = `${basePath}/${matchPath}`.replace(/\/$/, "");
  const currentDir = matchPath.split("/").pop() || "";
  const { targetTree, fileInfo } = useFileTree(tree, currentDir);

  return (
    <div>
      <h1>名前: {fileInfo.name}</h1>
      {targetTree &&
        targetTree.type === "folder" &&
        targetTree.children.length > 0 && (
          <div>
            <TreeView object={targetTree.children} />
          </div>
        )}
        // ファイルであれば内容を表示する様な実装
    </div>
  );
};
```
</details>

### 見た目を整える

デザインを整え、パンくずリストと上へボタンを追加しました。
![treeeee.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3512467/e08fcd00-a391-be6e-fdc3-d5630cd0a44f.gif)

## :straight_ruler: おわりに

`React Router` で動的かつ可変長なパスを表現してみました。

`basePath` との合成などは力技ですので、生成したパスが間違っていないか注意が必要です。

今回の実装の場合、`name` が同一の要素が存在すると別の内容が表示されてしまうかもしれません。より正確にマッチングさせる場合はツリー構造のオブジェクトにファイルパスの情報を持たせて比較させると探索時の計算やパス生成が容易になりそうです。

## :ledger: 参考

- [Home v6.23.0 | React Router](https://reactrouter.com/en/main)
- [reactjs - how to create deep paths in a tree structure in react-router v6 - Stack Overflow](https://stackoverflow.com/questions/72233349/how-to-create-deep-paths-in-a-tree-structure-in-react-router-v6)