Closed6

TanStack Routerについて調べた

TommyTommy

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>
      },
    })
    

    file-based routing

  • 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>,
      )
    }
    
    
webdevwebdev

Type Safety

Tanstack Routerの型安全性

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との型不一致エラーが警告されるようになり漏れを抑止する
webdevwebdev

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.tsxblog配下に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制御の取り回しが使い良いかどうかに期待。

気になった点

  • ファイルベースでルーティングを出し訳(認証前後)する場合、どのように実装するのか
TommyTommy

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)に基づいてデータをキャッシュし、効率的にデータを管理している。

  1. パス名

/posts/1/posts/2は別のデータとしてキャッシュ

  1. 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, // ナビゲーション時や依存関係の変更時のみリロード
    });
    
TommyTommy

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を継承できる。値も型も対象。
  • 親子関係ではないコンポーネント同士も、useSearchfromオプションで指定すればsearch paramsを参照できる。
  • getRouteApiを使って、親子関係にないルートのparamsも参照できる。
    • たとえば、layoutに設置しているナビのカレント表示など。

      const CategoryMenu = () => {
        const shopRouteApi = getRouteApi('/news')
        const { category } = shopRouteApi.useSearch()
        // categoryに基づいてメニュー項目をハイライト
      }
      
      • layout
        • CategoryMenu
        • page
          • hoge?
    • テストコードでルート検証したいとき簡単

      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>
    
webdevwebdev

Authenticated Route

router.beforeLoad

  • ルートがロードされる前に呼び出される関数を指定するオプション
    • 認証の有無を判定してから遷移先を出し分けることができる
  • ルートにアクセスした時に発生する事象の順序
    1. route.params.parse … パラメータの抽出
    2. route.validateSearch … 検索パラメータの検証
    3. route.beforeLoad (親ルート)
    4. route.beforeLoad (子ルート)
    5. route.onError… beforeLoad関数でのエラーが発生時
    6. route.component.preload? … コンポーネントの事前読み込み
    7. 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ファイル毎にルートの条件分岐を書くことになるのが面倒そう。
このスクラップは1ヶ月前にクローズされました