🌳

既存の Vite + React プロジェクトに TanStack Router をあとから導入する

2024/12/21に公開

はじめに

Vite + React で作った単一ページのサイトを拡張する必要が出てきたので、そのためルーティングを実装したい。
このルーティングをやってくれるライブラリとして TanStack Router というものがあり、以前から気になっていたので使ってみることにした。

https://tanstack.com/router/latest

だが、まっさらな状態から導入する例や機能を端的にまとめた記事などが多く、どうやって途中から導入していけばいいのかよくわからなかったので備忘録がてら記事を書いてみた。とはいえ、基本的にはpnpm create viteの状態からの構築方法としても読めるようにしたので、参考になれば幸いだ。

前提

  • pnpm create viteなどで Vite の React プロジェクトが作成されている。(react-swc-ts
  • パッケージマネージャーとしてpnpmを筆者は使っているが、npmなどの他のツールに読み替えても特に問題はないと思われる。
  • Vite や React についての基礎的な知識はあるものとする。
  • TanStack Router v1 を用いる。
  • File-based なルーティングを採用する。

準備

まずは以下のコマンドから TanStack Router をインストールする。

pnpm add @tanstack/react-router
pnpm add -D @tanstack/router-plugin

公式では@tanstack/router-devtoolsも入れる誘導になっているが、個人的にはすでに公開しているサイトだったのでこの記事では使わない。

次に Vite の設定ファイルを以下のように修正する。

vite.config.js
  import { defineConfig } from "vite";
  import react from "@vitejs/plugin-react-swc";
+ import { TanStackRouterVite } from "@tanstack/router-plugin/vite";

  // https://vite.dev/config/
  export default defineConfig({
-   plugins: [react()],
+   plugins: [react(), TanStackRouterVite()],
  });

TanStack Router の導入

main.tsxの変更

まずは破壊的な変更を加えていく。TanStack Router を使う上でsrc/main.tsxを以下の内容をコピペして全て書き換える。

src/main.tsx
import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'

// Import the generated route tree
import { routeTree } from './routeTree.gen'

// Create a new router instance
const router = createRouter({ routeTree })

// Register the router instance for type safety
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

// Render the app
const rootElement = document.getElementById('root')!
if (!rootElement.innerHTML) {
  const root = ReactDOM.createRoot(rootElement)
  root.render(
    <StrictMode>
      <RouterProvider router={router} />
    </StrictMode>,
  )
}

これは公式の導入にもあり、そこからそのままコピペしている。
https://tanstack.com/router/latest/docs/framework/react/quick-start#srcmaintsx

./routeTree.genがない!と Linter から怒られるだろうが、routeTree.gen.tsというファイルは開発サーバーを立ち上げる時に自動生成されるので無視しよう。

Root の設定

次にsrc/routesフォルダを作成する。このフォルダがルーティングの起点になる。
そして、その中に以下のような__root.tsxファイルを作る。

src/routes/__root.tsx
import { createRootRoute, Outlet } from "@tanstack/react-router";

export const Route = createRootRoute({
  component: () => (
    <>
      <Outlet />
    </>
  ),
});

<Outlet />は現在の位置に対して子にあるコンポーネントを表すものである。まあ、childrenのようなものである。ここで定義されているのは全てのルートの根(Root)なので、ページ全てに適用されるものを記述する。

例えば MUI などを使っていてページ全体に常に適用したい(表示したい)要素などがあれば、<Outlet />コンポーネントを囲むように以下のように配置する。

src/routes/__root.tsx
  import { createRootRoute, Outlet } from "@tanstack/react-router";
+ import { ThemeProvider, CssBaseline, createTheme } from "@mui/material";

+ const theme = createTheme({ /* ... */ });

  export const Route = createRootRoute({
    component: () => (
      <>
+       <ThemeProvider theme={theme}>
+         <CssBaseline />
          <Outlet />
+       </ThemeProvider>
      </>
    ),
  });

App.tsxの移動

おそらく SPA でそのままページを作っているのであればApp.tsxを使っているのではないかと思われるので、これを認識させる方法をとる。App.tsxでなくてもページを構成するメインのコンポーネントがあるファイルを移動する。
File-based なルーティングを採用するので、ルーティングの起点(src/routes)のファイル、つまりindex.tsxとして配置しなおす。

mv src/App.tsx src/routes/index.tsx

こうすることで、トップページに表示されるコンポーネントとして認識される。

もし、複数のファイルにコンポーネントを分割している場合、構成要素はsrc/routes/-componentsフォルダに移動するのが良いだろう。

mv src/components/Example.tsx src/routes/-components/Example.tsx

-componentsフォルダの-はルートとして認識させないための prefix である。[1]これがないと例えばsrc/routes/components/Example.tsxhttps://<frontend>/components/Exampleという URL にアクセスできることになってしまう。

これが終わったら各ファイル内のimportのパスを解決して、index.tsxに以下の内容を追加する。

src/routes/index.tsx
  /* imports */
+ import { createFileRoute } from "@tanstack/react-router";


  // トップページに表示されるコンポーネント
  function App() {
    /* コンポーネントの実装 */
  }

- export default App;
+ export const Route = createFileRoute("/")({
+   component: App,
+ });

Linter がcreateFileRouteの引数"/"に対してエラーを指摘してくるが、これもrouteTree.gen.tsが生成されることで解決する。逆にrouteTree.gen.tsさえ生成されてしまえば、こういったルーティングで利用する文字列は今後補完が効くようになる。(これが便利!)

開発サーバーを立ち上げる

これで準備が整ったので以下のコマンドから開発サーバーを立ち上げよう。

pnpm dev

するとsrc/routeTree.gen.tsが自動的に生成され、localhostを開けば期待通りトップページに設定したコンポーネントが描画されているはずである。
もちろん、今まで Linter に指摘されていたところも指摘してこなくなるだろう。ちなみに開発サーバーを立ち上げている間はsrc/routesフォルダは監視され、ルーティングを追従して解決してくれる。

これで、既存の React アプリを TanStack Router に乗せることはできた。

ルーティング機能を使ってみる

これで当初の目的である既存の React アプリに TanStack Router を導入した。これでおしまい、といってもいいのだが、さすがにルーティング機能が使われていないままなのはよろしくない。
ただ、機能紹介は他の方が詳細な記事[2]を書いていたり、そもそも公式を参照すれば良いので、個人的に良かった(使った)機能を紹介する。

NotFound ページをカスタマイズ

現状のままではルートがトップページしかないので、/path/to/pageのようなパスのページを見に行こうとしても"Not Found"とそっけないメッセージがあるだけである。
例えば、src/NotFound.tsxファイルを作成し、以下のようなコンポーネントを定義する。

src/NotFound.tsx
export function NotFound() {
  return <>ここには何もないですよ。</>;
}

(これも大して変わらんだろ。)
NotFound ページの書き換えは以下のようにmain.tsxを書き換えてできる。

src/main.tsx
  import { StrictMode } from 'react'
  import ReactDOM from 'react-dom/client'
  import { RouterProvider, createRouter } from '@tanstack/react-router'
+ import { NotFound } from "./NotFound";

  // Import the generated route tree
  import { routeTree } from './routeTree.gen'

  // Create a new router instance
- const router = createRouter({ routeTree })
+ const router = createRouter({
+   routeTree,
+   defaultNotFoundComponent: NotFound,
+ });

  /* これ以降は同じ */

ちなみにエラーページやローディングページなども同様の方法で実装できる。ただ、ここで設定されるコンポーネントは本当に全てに適応されるものであって、それとは別に特定のルートにおける実装もできる。
詳細は以下の公式の説明を見ると良い。
https://tanstack.com/router/v1/docs/framework/react/guide/not-found-errors

loader によるデータフェッチと結果による切り替え

静的サイトではなく、何らかのバックエンドと通信する動的サイトを作ることを考える。
例えば、https://<frontend>/articles/<articleId>を開くとarticleIdに一致する文章を表示するようなものを考える。

src/routes/articles/$articleId.tsxファイルを作成すると、(開発サーバーを立ち上げたままなら)以下のようなテンプレートが自動で生成される。

src/routes/articles/$articleId.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/articles/$articleId')({
  component: RouteComponent,
})

function RouteComponent() {
  return <div>Hello "/articles/$articleId"!</div>
}

$articleId$は動的パスパラメータを表し、ここに指定された文字列を値として受け取れるようになる。[1:1]
例えば以下のように書き換えると、指定されたarticleIdがあればその内容を表示し、なければnotFoundエラーで NotFound ページを表示できる。

src/routes/articles/$articleId.tsx
- import { createFileRoute } from '@tanstack/react-router'
+ import { createFileRoute, notFound } from "@tanstack/react-router";

+ type Article = {
+   title: string;
+   author: string;
+   text: string;
+ };

  export const Route = createFileRoute("/articles/$articleId")({
+   loader: async ({ params: { articleId } }) => {
+     try {
+       const response = await fetch(`https://<backend>/articles/${articleId}`);
+       return (await response.json()) as Article; // 例のためであって、あまりやってはいけない記法
+     } catch {
+       throw notFound();
+     }
+   },
    component: RouteComponent,
+   notFoundComponent: () => {
+     const { articleId } = Route.useParams();
+     return <>'{articleId}' で指定された文章はありませんよ</>;
+   },
  });

  function RouteComponent() {
+   const article: Article = Route.useLoaderData();
-   return <div>Hello "/articles/$articleId"!</div>
+   return (
+     <div>
+       <h1>{article.title}</h1>
+       <h3>{article.author}</h3>
+       {article.text}
+     </div>
+   );
+ }

https://tanstack.com/router/v1/docs/framework/react/guide/data-loading

リダイレクト

リダイレクトも簡単で、トップページから上の例で作った特定のarticleIdのページに飛ばす時は以下のようにsrc/routes/index.tsxを修正すれば良い。

src/routes/index.tsx
- import { createFileRoute } from "@tanstack/react-router";
+ import { createFileRoute, redirect } from "@tanstack/react-router";

- // トップページに表示されるコンポーネント
- function App() {
-   /* コンポーネントの実装 */
- }

  export const Route = createFileRoute("/")({
+   loader: () => {
+     throw redirect({
+       to: "/articles/$articleId",
+       params: {
+         articleId: "introduction",
+       },
+     });
+   },
-   component: App,
  });

終わりに

パスやパスパラメータにバッチバチに推論が効くので TanStack Router の使い心地はとても良かった。今後も使っていきたい。

それと、本記事の具体的なモチベーションについては別記事として書いたので、もし興味があれば以下の記事も見ていただけるとありがたい。
https://zenn.dev/huyu_kotori/articles/2024-12-12-0-make-website-scalable

脚注
  1. ファイル名に関する公式の詳細な説明: https://tanstack.com/router/v1/docs/framework/react/guide/file-based-routing ↩︎ ↩︎

  2. https://zenn.dev/aishift/articles/ad1744836509dd ↩︎

不遊小鳥

Discussion