TanStack Routerについて調べた
![Tommy](https://res.cloudinary.com/zenn/image/fetch/s--1yOwTl9i--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/c586ee0f2f.jpeg)
ReactRouterとの比較
ルーティングに関する基本的な機能の提供は両者に特別の差異なし。
- 履歴
- ネスト
- 共通レイアウト・404
- Suspense
- File-based Route
共通
- ルーティング(共通レイアウト)、データ取得
Tanstackにアドバンテージ
- 型安全性
- より複雑な状態管理が可能なSearchParams hooks
- bundle size: 18.3KB
ReactRouterにアドバンテージ
- データ更新(Mutation)ができる
- コミュニティの広さ・実績
- bundle size: 24.4KB
code-based routing
-
ページごとのルーティングを手動で定義する方法。
const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: function Index() { return ( <div className="p-2"> <h3>Welcome Home!</h3> </div> ) }, }) const aboutRoute = createRoute({ getParentRoute: () => rootRoute, path: '/about', component: function About() { return <div className="p-2">Hello from About!</div> }, })
-
cliでルートパスを自動生成する方法。公式はfile-based routingを推奨。
// src/routes/__root.tsx -------------------------------------- import { createRootRoute, Link, Outlet } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' //共通のレイアウトを定義 export const Route = createRootRoute({ component: () => ( <> <div className="p-2 flex gap-2"> <Link to="/" className="[&.active]:font-bold"> Home </Link>{' '} <Link to="/about" className="[&.active]:font-bold"> About </Link> </div> <hr /> <Outlet /> <TanStackRouterDevtools /> </> ), }) // src/routes/index.lazy.tsx -------------------------------------- import { createLazyFileRoute } from '@tanstack/react-router' //ページのパスを定義 export const Route = createLazyFileRoute('/')({ component: Index, }) //ページコンポーネント function Index() { return ( <div className="p-2"> <h3>Welcome Home!</h3> </div> ) } // src/routes/about.lazy.tsx -------------------------------------- import { createLazyFileRoute } from '@tanstack/react-router' export const Route = createLazyFileRoute('/about')({ component: About, }) function About() { return <div className="p-2">Hello from About!</div> } // `tsr watch` or `tsr generate` を実行 // `src/routeTree.gen.ts` が自動生成 // `routeTree.gen.ts`をappで読み込む import React, { 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>, ) }
Type Safety
Tanstack Routerの型安全性
-
<Link />
やuseNavigate
を使用して画面遷移をする際に、パスのコード補完を行ってくれる- to のパスに、存在しないパスを記述すると型エラーになる
- path paramsやsearch paramsについても型を補完してくれる
TypeScriptのモジュール宣言
- Routerインスタンスの型推論を効かせるためには、アプリケーション内でモジュールを定義する必要がある。
- 下記のようにすることで、プロジェクト全体でRouterの型定義を参照できるようになる。
// src/app.tsx
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
ファイルベースのルーティング
- コードベースのルーティングをする場合、親子関係の定義を正しくコーディングする必要がある(親ルートの型を認識するため)
- Routeが増えるとその分コードベースが膨大になる
import { createRoute, lazyRouteComponent } from '@tanstack/react-router'
import { postsRoute } from './postsRoute'
export const postsIndexRoute = createRoute({
getParentRoute: () => postsRoute, // 親ルーター
path: '/', // 自身のパス
component: lazyRouteComponent(() => import('../page-components/posts/index')), // コード分割の設定
})
- 他方、ファイルベースのルーティングの場合、コードベースを減らすことができる
// src/routes/posts/index.lazy.ts => ファイル配置から自明にpostsが親であることがわかる
import { createLazyFileRoute } from '@tanstack/react-router'
export const Route = createLazyFileRoute('/posts/')({
component: () => 'Posts index component goes here!!!',
})
RouteTreeを生成
- 全Routeを統合したRouteTreeを定義するファイル(src/routeTree.gen.ts)が自動生成される
- 生成される routeTree をもとに router インスタンスを生成することで、アプリ全体で生成したRouteオブジェクトの構造を共有できる
import React, { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
// 生成されたRouteTreeをインポート
import { routeTree } from './routeTree.gen'
// routerのインスタンスを生成
const router = createRouter({ routeTree })
// routerインスタンスの型を定義
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// appを描画
const rootElement = document.getElementById('app')!
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)
}
気になった点
- 開発途中でRouteの構成を変えたとき、以前設定しており、修正し直せていないところにエラーが出る?
- 変更した Routeに対して接続している他のコンポーネントに、routeTreeとの型不一致エラーが警告されるようになり漏れを抑止する
File-based routing
導入:
- 2024/07時点の対応環境は3種類。要望があれば増やすと解説してある。
- Vite
- Rspack/Rsbuild … RustベースのJavaScriptバンドラーらしい
- Webpack
-
@tanstack/router-plugin
をインストールすると使えるようになる。環境によってimportするデータは違っている
// vite.config.ts
import { defineConfig } from 'vite'
import viteReact from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
TanStackRouterVite(),
viteReact(),
// ...
],
})
-
src/routes
ディレクトリを作成し、その配下へファイルを配置していくとファイル構造に基づいて自動でルート定義を作成する。-
src/routes/index.js
は/
-
src/routes/about.js
は/about
-
カスタマイズ:
-
tsr.config.json
というファイルを設置すれば細かく設定できる- ルートディレクトリ、生成されるルートツリーファイルの場所、無視するファイルのプレフィックスなどを設定できる
動的定義:
- ファイル名に
[param]
形式を使用すると動的パラメータを定義できる- 例:
src/routes/posts/[postId].js
は/posts/:postId
に対応
- 例:
レイアウト:
-
layout.js
というファイルを設置すれば設定できる- 例:
src/routes/dashboard/layout.js
はダッシュボード内のすべてのルートの共通レイアウトを提供
- 例:
ファイル命名規則が柔軟に用意されている:
-
__root.tsx
:ルートのルートファイル-
routesDirectory
(初期設定ではsrc/routes
ディレクトリ)の直下に配置
-
-
.
:ネストされたルートを表す- 例:
blog.post.tsx
はblog
配下にpost
を生成しtsx
を設置しているのと同じ
- 例:
-
$
:パラメータ化されたルートセグメント- 例:
$postId.tsx
は動的なpost ID
- 例:
-
_
プレフィックス:レイアウトルート- 例:
_layout.tsx
- 例:
-
_
サフィックス:親ルートからの除外- 例:
profile_.tsx
- 例:
-
(folder)
:ルートグループ- 例:
(auth)/login.tsx
auth関連のルートを整理するがネストは適用させない
- 例:
-
index
:ルートパスに紐付ける- 例:
blog/index.tsx
は/blog
と一致
- 例:
-
.route.tsx
:ディレクトリ内のルートファイル -
.lazy.tsx
:そのルートが必要になるまではロードさせない
アイデア
- Next.jsを搭載するにはオーバースペックなケースで、Vite + TansStackRouterが正解になるかもしれないので、Auth制御の取り回しが使い良いかどうかに期待。
気になった点
- ファイルベースでルーティングを出し訳(認証前後)する場合、どのように実装するのか
![Tommy](https://res.cloudinary.com/zenn/image/fetch/s--1yOwTl9i--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/c586ee0f2f.jpeg)
Data Loading
SWR (Stale-While-Revalidate) キャッシング
- ルートローダー用の組み込みキャッシング層
- SWRのキャッシュ機構を使用している
Router Cacheを使うか、別のキャッシュ機構を使うか
Router Cacheのメリット
- 組み込みで使いやすい
- ルートごとに重複排除、プリロード、ローディングなどの処理が可能
Router Cacheのデメリット
- 永続化のためのアダプターやモデルがない
- セッションの中にキャッシュを保存する=セッションが切れるとキャッシュも失われる
- ルート間でのキャッシュの共有や重複排除がない
- 異なるルートで同じデータを取得しても、キャッシュは共有されない
- 更新系のAPIがない
⇨ Router Cachは小規模〜中規模のアプリケーションに向いている
Route Loader
- ルートがマッチされると呼び出される関数
// routes/posts.tsx
export const Route = createFileRoute('/posts')({
loader: () => fetchPosts(),
})
- loader関数はパラメータを取ることができ、より細かな条件を設定できる
async function loader({
abortController,
cause,
context,
deps,
location,
params,
parentMatchPromise,
preload,
route,
}) {
const signal = abortController.signal;
// ルートが変更された時に、データ取得をキャンセル
if (signal.aborted) {
return;
}
try {
const response = await fetch(`https://api.example.com/data/${params.id}`, {
signal,
});
if (!response.ok) {
throw new Error('Failed to fetch data');
}
const data = await response.json();
return {
data,
cause,
preload,
routeName: route.name,
};
} catch (error) {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Failed to load data', error);
}
}
}
- preloadにも対応
- Linkをホバーした時に、loader関数が呼び出されデータを取得してくれる
キャッシュの依存関係
ルートの依存関係(devendencies)に基づいてデータをキャッシュし、効率的にデータを管理している。
- パス名
/posts/1
と/posts/2
は別のデータとしてキャッシュ
- laoderDepsによって提供された追加の依存要素
例: loaderDeps: ({ search: { pageIndex, pageSize } }) => ({ pageIndex, pageSize })
ページネーションなどの検索パラメータを依存関係として指定。
キャッシュの制御
-
staleTime
- キャッシュしたデータをstale状態にするまでの時間。
- defaultは
0
export const Route = createFileRoute('/posts')({ loader: () => fetchPosts(), staleTime: 10_000, // データを10秒間新鮮とみなす });
-
gcTime
- 使用されなくなったキャッシュを破棄するまでの時間。
- defaultは
30 min
export const Route = createFileRoute('/posts')({ loaderDeps: ({ search: { offset, limit } }) => ({ offset, limit }), loader: ({ deps }) => fetchPosts(deps), gcTime: 0, // アンロード後にキャッシュを削除 shouldReload: false, // ナビゲーション時や依存関係の変更時のみリロード });
![Tommy](https://res.cloudinary.com/zenn/image/fetch/s--1yOwTl9i--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_70/https://storage.googleapis.com/zenn-user-upload/avatar/c586ee0f2f.jpeg)
Search Params
JSON形式のsearch params:
-
複雑な値をJSONのまま扱える。
const link = ( <Link to="/shop" search={{ pageIndex: 3, includeCategories: ['electronics', 'gifts'], sortBy: 'price', desc: true, }} /> )
-
配列、数値、booleanなどもオート判別
型安全:
-
validateSearch
を使って手間を掛けずに型安全を検証できる -
search paramsの型エラーのとき、ルートコンポーネントのかわりにerrorComponentが表示される仕組みが標準搭載されている
- 予期していないsearch paramsが設定されたときガイドを掲載する処理をぜんぶ手作りしなくてよい。
-
ライブラリzodを使って拡張すると、値が不正なときデフォルト値を代入する対応もスマートに定義できる
// /routes/shop.products.tsx import { z } from 'zod' const productSearchSchema = z.object({ page: z.number().catch(1), // エラーは返さず1を代入 filter: z.string().catch(''), // エラーは返さず空文字を代入 sort: z.enum(['newest', 'oldest', 'price']).catch('newest'), // エラーは返さずnewestを代入 }) type ProductSearch = z.infer<typeof productSearchSchema> export const Route = createFileRoute('/shop/products')({ validateSearch: (search) => productSearchSchema.parse(search), })
別コンポーネントsearch paramsを参照:
- 子ルートは親ルートのsearch paramsを継承できる。値も型も対象。
- 親子関係ではないコンポーネント同士も、
useSearch
のfrom
オプションで指定すればsearch paramsを参照できる。 -
getRouteApi
を使って、親子関係にないルートのparamsも参照できる。-
たとえば、layoutに設置しているナビのカレント表示など。
const CategoryMenu = () => { const shopRouteApi = getRouteApi('/news') const { category } = shopRouteApi.useSearch() // categoryに基づいてメニュー項目をハイライト }
- layout
- CategoryMenu
- page
- hoge?
- layout
-
テストコードでルート検証したいとき簡単
test('should handle search params', () => { const routeApi = getRouteApi('/products') // routeApiを使用してテストケースをセットアップ })
-
更新API:
-
<link>
,useNavigate
,navigate
,router.navigate
,<Navigate>
で記述が共通。 -
引数で変更前の値を参照できる。相対的な変更の指定が効率的。
<Link from={allProductsRoute.fullPath} search={(prev) => ({ page: prev.page + 1 })} > Next Page </Link>
Authenticated Route
router.beforeLoad
- ルートがロードされる前に呼び出される関数を指定するオプション
- 認証の有無を判定してから遷移先を出し分けることができる
- ルートにアクセスした時に発生する事象の順序
-
route.params.parse
… パラメータの抽出 -
route.validateSearch
… 検索パラメータの検証 route.beforeLoad
(親ルート)route.beforeLoad
(子ルート)-
route.onError
… beforeLoad関数でのエラーが発生時 -
route.component.preload?
… コンポーネントの事前読み込み -
route.load
… ルートが完全に読み込まれる際に実行(データフェッチ等を行う箇所)
-
- アクセス対象のルートと、その子ルートの全てのbeforeLoad関数よりも先に実行される
- ミドルウェア的な役割
- 親ルートのbeforeLoad関数が呼び出され、次に子ルートのbeforeLoad関数が呼び出される
beforeLoad関数の使い方
// src/routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
// 認証状態をチェック
if (!isAuthenticated()) {
throw redirect({
to: '/login', // 認証されていない場合にリダイレクトする
search: {
redirect: location.href, // 認証されている場合、現在のパスにリダイレクトする(現在地から動かない)
},
})
}
},
})
下記のような書き方もできる(同じページ内で表示を出し分ける場合)
// src/routes/_authenticated.tsx
export const Route = createFileRoute('/_authenticated')({
component: () => {
// 認証状態をチェック
if (!isAuthenticated()) {
return <Login />
}
return <Outlet />
},
})
Contextから認証状態を取得する
-
createRootRouteWithContext
… コンテクストとルートの繋ぎ込みをしてくれるメゾット- Contextを定義
- App全体をContext Providerで囲う
- rootLayoutでContextとRouteの繋ぎ込みを行う
- ページファイルでContextの値を参照する
/src/main.tsx
// Routerインスタンスの定義
const router = createRouter({
routeTree,
defaultPreload: 'intent',
context: {
auth: undefined!, // 引数で渡されたauthの情報が後で入る
})
// Typeの宣言
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
// contextで管理する認証情報を、routeに共有
function InnerApp() {
const auth = useAuth()
return <RouterProvider router={router} context={{ auth }} />
}
function App() {
return (
<AuthProvider>
<InnerApp />
</AuthProvider>
)
}
const rootElement = document.getElementById('app')!
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
}
/src/root/__root.tsx
import { createRootRouteWithContext } from '@tanstack/react-router'
interface MyRouterContext {
// Contextの型
auth: AuthState
}
export const Route = createRootRouteWithContext<MyRouterContext>()({
component: () => <Outlet />,
})
/src/root/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/dashboard')({
// ルートへのアクセス前に走る
beforeLoad: ({ context, location }) => {
// contextで共有された認証情報を参照する
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/login',
search: {
redirect: location.href,
},
})
}
},
})
アイデア
- beforeLoadの使い勝手
- ページ間遷移のTrasition作られますね。
- ブラウザでhistory back forwardしたときの、ページネーションやフィルタのパラメータやスクロール位置復元にも使えそう
- 同じページ内で表示を出し分ける方法、設定は手間だけど外部から認証後コンテンツへのリンクをふんだとき(メルマガ内から誘導するリンク等)、認証がおわってから本来出したかったコンテンツへ連れ戻す工程がシンプルにできてよさそう。
気になった点
- PoC系の記事が少なく、Context以外を使用した状態管理の方法が出てこなかった。公式DocでもContextを使用した事例が紹介されている為、Contextを使用するのが王道のやり方なのか?
-
AuthGuard
のような、使いまわせる関数を使用しないと、グループ別に分けたindexファイル毎にルートの条件分岐を書くことになるのが面倒そう。