TanStack Routerを使いながらReactを再考する
まえがき
みなさんこんにちは、こんばんは、ツッチーです。最近業務ではインフラ領域ばかりなのですが、忘れない程度にアプリケーション領域にも触れていこうと思う今日この頃です。
突然ですがReactでルーティングを実装するときみなさんは何を使いますか?こちらとしてはただ「File-basedルーティングがしたい」だけなのですが、Next.jsだとSPA開発においては過剰だし、React Routerのみではそもそも実現できない、というモヤモヤがありました。そんなニッチなニーズ(いや思っている方は多いはず)に答えてくれるのが今回ご紹介するTanStack Routerです。
時期バージョンであるReact Router v7ではRemixと統合され、File-basedルーティングが導入されるようですが、基本はCode-basedルーティングで、あくまでオプショナルとしてFile -basedルーティングが提供されるようです。
TanStack Router ??
非同期の状態管理ライブラリとして有名なTanStack Query(旧: React Query)と開発元を同じくするReactルーティングライブラリです。リリースが2023年12月ということもあり比較的新しいライブラリですね。公式には以下のように記載されています。
Typesafe & powerful, yet familiarly simple
型安全で強力、かつシンプル
Built-in Data Fetching with Caching
キャッシュ機能を備えた組み込みデータ取得。同じ開発元であるTanstack Queryをクライアント側のキャッシュライブラリとして連携することもできます。
Search Param APIs to make your state-manager jealous
Web標準であるURLSeachParamsを超える検索パラメータAPI
File-basedルーティング ??
ファイルシステムを使用してルートを構成する方法です。コードでルート構造を定義する(こちらをCode-basedルーティングと言います)代わりに、ファイルとディレクトリ構造を使用してルートを定義します。
今回使用するTanStack Routerにおけるルールに以下になります。Next.jsを触ったことがある方でしたら違和感なく理解できると思います。詳細はコチラをご覧ください。
routes
├── __root.tsx # 全てのルートにマッチします
├── index.tsx # インデックスルートになります(localhost:3000/)
├── about.tsx # そのままパスになります(localhost:3000/about)
└── todos
├── $todoId # パスパラメータになります
│ └── index.tsx #(localhost:3000/todos/$todoId)
└── index.tsx # ディレクトリ名がパスになります(localhost:3000/todos)
さっそく使ってみる
Vite+Reactでアプリを構築します。TanStack Queryも併せて設定しますが、今回やりたいことはTanStack RouterによるFile-basedルーティングなので詳細については割愛します。
$ bun create vite react-vite-app
$ bun add @tanstack/router @tanstack/query
$ bun add -D @tanstack/router-devtools @tanstack/react-query-devtools
今回のメインとなるルータを作成します。ん?生成されたルートツリーをインポートします?そんなものありませんね。
import { routeTree } from './routeTree.gen' // こんなファイルは存在しない
どうやらバンドラーごとにプラグインが用意されているようです。各バンドラーのビルドプロセスに合わせてルート構成を自動で生成してくれるようです。
$ bun add -D @tanstack/router-plugin
vite.config.ts
に設定を追加します。
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths(), TanStackRouterVite()],
});
デフォルトの構成は以下のようになっています。というわけで/routes/index.tsx
を作成してビルドしてみます。
{
"routesDirectory": "./src/routes",
"generatedRouteTree": "./src/routeTree.gen.ts",
"routeFileIgnorePrefix": "-",
"quoteStyle": "single"
}
$ bun run build
おおsrc/routeTree.gen.ts
が作成されました
src
├── main.tsx
├── routeTree.gen.ts
├── routes
│ └── index.tsx
└── vite-env.d.ts
- (ご参考)生成されたコード
-
/* eslint-disable */ // @ts-nocheck // noinspection JSUnusedGlobalSymbols // This file was automatically generated by TanStack Router. // You should NOT make any changes in this file as it will be overwritten. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Import Routes import { Route as rootRoute } from "./routes/__root"; import { Route as OoopsImport } from "./routes/ooops"; import { Route as IndexImport } from "./routes/index"; import { Route as TodosIndexImport } from "./routes/todos/index"; import { Route as TodosTodoIdIndexImport } from "./routes/todos/$todoId/index"; // Create/Update Routes const OoopsRoute = OoopsImport.update({ id: "/ooops", path: "/ooops", getParentRoute: () => rootRoute, } as any); const IndexRoute = IndexImport.update({ id: "/", path: "/", getParentRoute: () => rootRoute, } as any).lazy(() => import("./routes/index.lazy").then((d) => d.Route)); const TodosIndexRoute = TodosIndexImport.update({ id: "/todos/", path: "/todos/", getParentRoute: () => rootRoute, } as any); const TodosTodoIdIndexRoute = TodosTodoIdIndexImport.update({ id: "/todos/$todoId/", path: "/todos/$todoId/", getParentRoute: () => rootRoute, } as any); // Populate the FileRoutesByPath interface declare module "@tanstack/react-router" { interface FileRoutesByPath { "/": { id: "/"; path: "/"; fullPath: "/"; preLoaderRoute: typeof IndexImport; parentRoute: typeof rootRoute; }; "/ooops": { id: "/ooops"; path: "/ooops"; fullPath: "/ooops"; preLoaderRoute: typeof OoopsImport; parentRoute: typeof rootRoute; }; "/todos/": { id: "/todos/"; path: "/todos"; fullPath: "/todos"; preLoaderRoute: typeof TodosIndexImport; parentRoute: typeof rootRoute; }; "/todos/$todoId/": { id: "/todos/$todoId/"; path: "/todos/$todoId"; fullPath: "/todos/$todoId"; preLoaderRoute: typeof TodosTodoIdIndexImport; parentRoute: typeof rootRoute; }; } } // Create and export the route tree export interface FileRoutesByFullPath { "/": typeof IndexRoute; "/ooops": typeof OoopsRoute; "/todos": typeof TodosIndexRoute; "/todos/$todoId": typeof TodosTodoIdIndexRoute; } export interface FileRoutesByTo { "/": typeof IndexRoute; "/ooops": typeof OoopsRoute; "/todos": typeof TodosIndexRoute; "/todos/$todoId": typeof TodosTodoIdIndexRoute; } export interface FileRoutesById { __root__: typeof rootRoute; "/": typeof IndexRoute; "/ooops": typeof OoopsRoute; "/todos/": typeof TodosIndexRoute; "/todos/$todoId/": typeof TodosTodoIdIndexRoute; } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath; fullPaths: "/" | "/ooops" | "/todos" | "/todos/$todoId"; fileRoutesByTo: FileRoutesByTo; to: "/" | "/ooops" | "/todos" | "/todos/$todoId"; id: "__root__" | "/" | "/ooops" | "/todos/" | "/todos/$todoId/"; fileRoutesById: FileRoutesById; } export interface RootRouteChildren { IndexRoute: typeof IndexRoute; OoopsRoute: typeof OoopsRoute; TodosIndexRoute: typeof TodosIndexRoute; TodosTodoIdIndexRoute: typeof TodosTodoIdIndexRoute; } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, OoopsRoute: OoopsRoute, TodosIndexRoute: TodosIndexRoute, TodosTodoIdIndexRoute: TodosTodoIdIndexRoute, }; export const routeTree = rootRoute ._addFileChildren(rootRouteChildren) ._addFileTypes<FileRouteTypes>(); /* ROUTE_MANIFEST_START { "routes": { "__root__": { "filePath": "__root.tsx", "children": [ "/", "/ooops", "/todos/", "/todos/$todoId/" ] }, "/": { "filePath": "index.tsx" }, "/ooops": { "filePath": "ooops.tsx" }, "/todos/": { "filePath": "todos/index.tsx" }, "/todos/$todoId/": { "filePath": "todos/$todoId/index.tsx" } } } ROUTE_MANIFEST_END */
改めてルータを作成しmain.tsx
に組み込みます。色々と余計なこともしているので要点だけまとめます。(説明の便宜上、1つのファイルにまとめておりますが、本開発時には適宜Providerを定義した方が可読性が向上すると思います)
- TanStack Queryをコンテキストとして依存性注入
- モックサーバ(MSW)の設定
- TanStack Router/Query DevToolsの設定
- Suspence/ErrorBoundaryの設定
import React, { StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { routeTree } from "@/routeTree.gen.ts";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorFallback } from "@/components/ErrorFallback";
// Step1: TanStack Queryのクライアント作成
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: false,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 5,
},
},
});
// Step2: ルータを作成
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreloadStaleTime: 1000 * 60 * 5,
defaultPreload: "intent",
});
// Step3: (重要)ルータの型を登録する
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
// Step4: モックサーバ(MSW)とTanStack Router/Query DevToolsのインポート
const setUp = async () => {
if (import.meta.env.DEV) {
React.lazy(() =>
import("@tanstack/router-devtools").then((res) => ({
default: res.TanStackRouterDevtools,
})),
);
}
if (import.meta.env.MODE !== "mock") return;
const { worker } = await import("@/services/api/mock/browser");
return worker.start();
};
// Step5: Reactコンポーネントルートを作成
setUp().then(() => {
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Suspense>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} context={{ queryClient }} />
</QueryClientProvider>
</ErrorBoundary>
</Suspense>
</StrictMode>,
);
});
__root.tsx
を作成します。ここでは共通レイアウトとしてヘッダーとDevToolsを組み込みます。
import Header from "@/components/Header";
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
import type { QueryClient } from "@tanstack/react-query";
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
// React Queryを注入
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
component: RootComponent,
});
function RootComponent() {
return (
<>
<Header />
<Outlet />
<ReactQueryDevtools buttonPosition="top-right" />
<TanStackRouterDevtools position="bottom-right" />
</>
);
}
あとはsrc/routes
ディレクトリにページを追加していくだけです。
routes
├── __root.tsx
├── index.lazy.tsx
├── index.tsx
├── ooops.tsx
└── todos
├── $todoId
│ └── index.tsx
└── index.tsx
完成しました。右上/右下にあるのはそれぞれTanStack Query/RouterのDevToolsです。
検証・考察してみる
こちらは/todos
のコードの一部になります。
const getTodosOptions = queryOptions({
queryKey: ["/todos"],
queryFn: () => getTodos(),
staleTime: 1000 * 60 * 5,
});
// ルート
export const Route = createFileRoute("/todos/")({
component: ToDosPage,
loader: async ({ context: { queryClient } }) => {
return await queryClient.ensureQueryData(getTodosOptions);
},
pendingComponent: () => <Loading />,
errorComponent: (err) => (
<ErrorFallback
error={err.error}
resetErrorBoundary={() => window.location.assign(window.location.origin)}
/>
),
});
// ページコンポーネント
function ToDosPage {
const { data: todos } = useGetTodosSuspense();
}
型安全
createFileRoute()
でルートを作成していくのですが、パスにコード補完が効いていますね。これは地味に嬉しいです。
データ取得
loader()
というSWRキャッシュ戦略を備えたローダー機能が組み込みで提供されています。データ取得という観点ではTanStack Queryと役割が重複していますが、公式には以下のように記載されています。
メリット
- 組み込みで使いやすい
- ルートごとに重複排除、事前ロード、再フェッチを行う
- 自動ガベージコレクション
デメリット
- 永続アダプタ/モデルはない
- ルート間でのキャッシュ/重複排除の共有はない
- 組み込みのミューテーション(更新)APIはない
なるほど、あくまでそのルートを描画するために必要なデータにだけ関心を持ち、ルート間でのやり取りや、状態管理は他でやってくださいということですね。今回は公式のプラクティス通り、ローダー側でTanStack QueryのensureQueryData()
を使ってデータを取得しつつキャッシュし、コンポーネント側ではuseQuery()
を使って取得したデータを参照するようコードを記述しています。
Suspence対応
個人的に今回一番感動したポイントです。コンポーネントが読み込み中なのか、失敗したのかをルート単位で定義することができます。
pendingComponent
: コンポーネントが読み込み中の場合に表示されるerrorComponent
: コンポーネントの読み込みに失敗した場合に表示される
ルートに上記のプロパティを定義することで、コンポーネント側で以下のように記述するのと同じことになります(多少語弊があります)。
<Suspense fallback={<FallbackComponent />}>
<ErrorBoundary FallbackComponent={<ErrorFallback />}>
<MyComponent />
</ErrorBoundary>
</Suspense>
つまりコンポーネントから非同期処理の状態遷移に対する関心を分離できるということです。Reactを書いている方なら分かると思いますが、コンポーネント内に非同期処理の状態遷移をハンドリングするあの煩わしいコードを排除することができます。これは感動ですね。
function ToDosPage() {
const { data, status } = useGetTodos();
// 状態遷移のハンドリング
if (status === "pending") return <Loading />;
if (status === "error") return <ErrorFallback error={data} resetErrorBoundary={() => {}} />;
return <Component />;
}
若干余談ですが重要なポイントとして、今回OpenAPI仕様書からOrval経由でクライアントコードを生成しているのですが、OrvalがSuspence Queryに対応したことも非常に追い風となっております。
Suspence Query?という方はコチラをご覧ください。
// orval.config.ts
export default defineConfig({
api: {
input: path.resolve(__dirname, "openapi.yaml"),
output: {
...
mock: true,
client: "react-query",
override: {
mutator: {},
// useSuspenceQuery()を生成する
query: {
useSuspenseQuery: true,
},
},
},
},
validation: {}
});
先ほど挙げた/todosのコードの再掲ですが、APIもHookも自動生成です。さらに言えばAPIのモック(msw)もバリデーション(zod)も自動生成です。もう人間がクライアントコードを書く時代は終わるような気がします。
const getTodosOptions = queryOptions({
queryKey: ["/todos"],
queryFn: () => getTodos(), // 自動生成
staleTime: 1000 * 60 * 5,
});
// ルート
export const Route = createFileRoute("/todos/")({
component: ToDosPage,
loader: async () => {},
pendingComponent: () => {},
errorComponent: (err) => {}
});
// ページコンポーネント
function ToDosPage {
const { data: todos } = useGetTodosSuspense(); // 自動生成
}
あとがき
当初はFile-basedルーティングの検証がしたかっただけなのですが、久しぶりにフロントエンドのコードを書いたこともあってか、色々と脱線してしまった感があります。技術の進歩は早いですね。Reactで一番煩わしい状態管理、ルーティングという領域を一挙に解決してくれるという意味でTanStack Router/Queryの組み合わせは有力な選択肢になると思いました。皆さんもステートレス(厳密にはありますが)でストレスフリーなアプリ開発をお試しください。