最終更新:

TanStack Router v2完全ガイド: 型安全なファイルベースルーティング


TanStack Router v2は、React向けの完全型安全なルーティングライブラリです。React RouterやNext.jsのApp Routerとは異なり、エンドツーエンドの型安全性を重視した設計が特徴です。本記事では、v2の新機能と実践的な活用方法を解説します。

TanStack Router v2の特徴

1. 完全な型安全性

TanStack Routerの最大の特徴は、ルート定義から検索パラメータ、ローダーデータまで、すべてが型推論される点です。

import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

// 検索パラメータのスキーマ定義
const userSearchSchema = z.object({
  page: z.number().default(1),
  perPage: z.number().default(20),
  sort: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
  order: z.enum(['asc', 'desc']).default('desc'),
})

export const Route = createFileRoute('/users/')({
  // 検索パラメータのバリデーション
  validateSearch: userSearchSchema,

  // ローダー関数(データフェッチ)
  loader: async ({ context, search }) => {
    // search は型推論される
    const users = await context.api.users.list({
      page: search.page,
      perPage: search.perPage,
      sort: search.sort,
      order: search.order,
    })
    return { users }
  },

  // コンポーネント
  component: UsersPage,
})

function UsersPage() {
  const { users } = Route.useLoaderData() // 完全に型付けされる
  const search = Route.useSearch() // 検索パラメータも型付けされる
  const navigate = Route.useNavigate()

  const handleSortChange = (sort: 'name' | 'email' | 'createdAt') => {
    // navigate の引数も型チェックされる
    navigate({
      search: (prev) => ({ ...prev, sort, page: 1 }),
    })
  }

  return (
    <div>
      <h1>Users ({users.total})</h1>
      {/* UI実装 */}
    </div>
  )
}

2. ファイルベースルーティング

v2では、ファイルシステムベースのルーティングがデフォルトでサポートされます。

src/routes/
├── __root.tsx              # ルートレイアウト
├── index.tsx               # /
├── about.tsx               # /about
├── users/
│   ├── index.tsx           # /users
│   ├── $userId.tsx         # /users/:userId
│   └── $userId/
│       ├── edit.tsx        # /users/:userId/edit
│       └── posts/
│           └── $postId.tsx # /users/:userId/posts/:postId
├── _auth/                  # レイアウトルート(URLに含まれない)
│   ├── login.tsx           # /login
│   └── register.tsx        # /register
└── settings/
    ├── _layout.tsx         # /settings のレイアウト
    ├── profile.tsx         # /settings/profile
    └── security.tsx        # /settings/security

3. ルート生成の自動化

TanStack Routerは、ファイル構造から自動的にルート定義を生成します。

# プロジェクトセットアップ
npm install @tanstack/react-router
npm install -D @tanstack/router-vite-plugin

# vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { TanStackRouterVite } from '@tanstack/router-vite-plugin'

export default defineConfig({
  plugins: [
    react(),
    TanStackRouterVite({
      // ルート定義の自動生成
      routesDirectory: './src/routes',
      generatedRouteTree: './src/routeTree.gen.ts',
    }),
  ],
})

開発中、ファイルを保存するたびにrouteTree.gen.tsが自動更新され、完全な型情報が提供されます。

実践: SaaS管理画面の構築

実際のSaaS管理画面を例に、TanStack Router v2の活用方法を見ていきます。

ルートレイアウト

// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
import { QueryClient } from '@tanstack/react-query'

interface RouterContext {
  queryClient: QueryClient
  auth: {
    user: User | null
    isAuthenticated: boolean
  }
}

export const Route = createRootRouteWithContext<RouterContext>()({
  component: RootLayout,
})

function RootLayout() {
  return (
    <>
      <Outlet />
      {import.meta.env.DEV && <TanStackRouterDevtools />}
    </>
  )
}

認証ルート

// src/routes/_auth.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'

export const Route = createFileRoute('/_auth')({
  // 認証チェック
  beforeLoad: async ({ context, location }) => {
    if (!context.auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: {
          redirect: location.href,
        },
      })
    }
  },
  component: AuthLayout,
})

function AuthLayout() {
  return (
    <div className="min-h-screen flex">
      <Sidebar />
      <main className="flex-1">
        <Outlet />
      </main>
    </div>
  )
}

ダイナミックルート

// src/routes/_auth/projects/$projectId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const projectSearchSchema = z.object({
  tab: z.enum(['overview', 'settings', 'members']).default('overview'),
})

export const Route = createFileRoute('/_auth/projects/$projectId')({
  validateSearch: projectSearchSchema,

  // パラメータとコンテキストの両方を使用
  loader: async ({ params, context }) => {
    const project = await context.queryClient.ensureQueryData({
      queryKey: ['projects', params.projectId],
      queryFn: () => api.projects.get(params.projectId),
    })

    if (!project) {
      throw new Error('Project not found')
    }

    return { project }
  },

  component: ProjectPage,
})

function ProjectPage() {
  const { project } = Route.useLoaderData()
  const { projectId } = Route.useParams()
  const { tab } = Route.useSearch()
  const navigate = Route.useNavigate()

  return (
    <div>
      <h1>{project.name}</h1>

      <Tabs value={tab} onValueChange={(tab) => navigate({ search: { tab } })}>
        <TabsList>
          <TabsTrigger value="overview">Overview</TabsTrigger>
          <TabsTrigger value="settings">Settings</TabsTrigger>
          <TabsTrigger value="members">Members</TabsTrigger>
        </TabsList>

        <TabsContent value="overview">
          <ProjectOverview project={project} />
        </TabsContent>
        <TabsContent value="settings">
          <ProjectSettings project={project} />
        </TabsContent>
        <TabsContent value="members">
          <ProjectMembers projectId={projectId} />
        </TabsContent>
      </Tabs>
    </div>
  )
}

検索パラメータの高度な活用

// src/routes/_auth/analytics.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { subDays } from 'date-fns'

const analyticsSearchSchema = z.object({
  startDate: z.string().datetime().default(() =>
    subDays(new Date(), 30).toISOString()
  ),
  endDate: z.string().datetime().default(() =>
    new Date().toISOString()
  ),
  metrics: z.array(z.enum(['views', 'clicks', 'conversions'])).default([
    'views',
    'clicks',
  ]),
  groupBy: z.enum(['day', 'week', 'month']).default('day'),
})

export const Route = createFileRoute('/_auth/analytics')({
  validateSearch: analyticsSearchSchema,

  loaderDeps: ({ search }) => ({
    startDate: search.startDate,
    endDate: search.endDate,
    metrics: search.metrics,
    groupBy: search.groupBy,
  }),

  loader: async ({ context, deps }) => {
    const data = await context.queryClient.ensureQueryData({
      queryKey: ['analytics', deps],
      queryFn: () => api.analytics.query(deps),
    })
    return { data }
  },

  component: AnalyticsPage,
})

function AnalyticsPage() {
  const { data } = Route.useLoaderData()
  const search = Route.useSearch()
  const navigate = Route.useNavigate()

  const updateDateRange = (startDate: Date, endDate: Date) => {
    navigate({
      search: (prev) => ({
        ...prev,
        startDate: startDate.toISOString(),
        endDate: endDate.toISOString(),
      }),
    })
  }

  const toggleMetric = (metric: 'views' | 'clicks' | 'conversions') => {
    navigate({
      search: (prev) => ({
        ...prev,
        metrics: prev.metrics.includes(metric)
          ? prev.metrics.filter((m) => m !== metric)
          : [...prev.metrics, metric],
      }),
    })
  }

  return (
    <div>
      <DateRangePicker
        startDate={new Date(search.startDate)}
        endDate={new Date(search.endDate)}
        onChange={updateDateRange}
      />

      <MetricsSelector
        selected={search.metrics}
        onToggle={toggleMetric}
      />

      <Chart data={data} groupBy={search.groupBy} />
    </div>
  )
}

エラーハンドリング

// src/routes/_auth/projects/$projectId/settings.tsx
import { createFileRoute, ErrorComponent } from '@tanstack/react-router'

export const Route = createFileRoute('/_auth/projects/$projectId/settings')({
  loader: async ({ params, context }) => {
    const [project, members] = await Promise.all([
      context.queryClient.ensureQueryData({
        queryKey: ['projects', params.projectId],
        queryFn: () => api.projects.get(params.projectId),
      }),
      context.queryClient.ensureQueryData({
        queryKey: ['projects', params.projectId, 'members'],
        queryFn: () => api.projects.members.list(params.projectId),
      }),
    ])

    // 権限チェック
    const currentUser = context.auth.user
    const isAdmin = members.some(
      (m) => m.userId === currentUser?.id && m.role === 'admin'
    )

    if (!isAdmin) {
      throw new Error('You do not have permission to access project settings')
    }

    return { project, members }
  },

  errorComponent: ({ error }) => (
    <ErrorComponent error={error} />
  ),

  component: ProjectSettingsPage,
})

ローダーのキャンセル

// src/routes/_auth/reports/$reportId.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_auth/reports/$reportId')({
  loader: async ({ params, abortController }) => {
    // AbortSignalをfetchに渡す
    const report = await fetch(
      `/api/reports/${params.reportId}`,
      { signal: abortController.signal }
    ).then((res) => res.json())

    // 長時間かかる処理の場合、定期的にチェック
    for (const chunk of processReport(report)) {
      if (abortController.signal.aborted) {
        throw new Error('Report processing cancelled')
      }
      await processChunk(chunk)
    }

    return { report }
  },

  component: ReportPage,
})

React Queryとの統合

TanStack RouterはReact Queryと完璧に統合できます。

// src/routes/_auth/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'

// クエリオプションを定義
const dashboardQueryOptions = () =>
  queryOptions({
    queryKey: ['dashboard'],
    queryFn: () => api.dashboard.getData(),
    staleTime: 1000 * 60 * 5, // 5分
  })

export const Route = createFileRoute('/_auth/dashboard')({
  // ローダーでプリフェッチ
  loader: ({ context }) =>
    context.queryClient.ensureQueryData(dashboardQueryOptions()),

  component: DashboardPage,
})

function DashboardPage() {
  // コンポーネント内でもクエリを使用可能
  const { data } = useSuspenseQuery(dashboardQueryOptions())

  return (
    <div>
      <h1>Dashboard</h1>
      <Stats data={data.stats} />
      <RecentActivity activities={data.activities} />
    </div>
  )
}

パフォーマンス最適化

1. ルートのコード分割

// src/routes/_auth/reports.tsx
import { createFileRoute } from '@tanstack/react-router'
import { lazy } from 'react'

const ReportsPage = lazy(() => import('../components/ReportsPage'))

export const Route = createFileRoute('/_auth/reports')({
  component: ReportsPage,
})

2. プリロード

import { Link } from '@tanstack/react-router'

function Navigation() {
  return (
    <nav>
      <Link
        to="/dashboard"
        // ホバー時にプリロード
        preload="intent"
      >
        Dashboard
      </Link>
      <Link
        to="/analytics"
        // 即座にプリロード
        preload="viewport"
      >
        Analytics
      </Link>
    </nav>
  )
}

3. 検索パラメータの最適化

import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

const listSearchSchema = z.object({
  page: z.number().default(1),
  search: z.string().default(''),
})

export const Route = createFileRoute('/items')({
  validateSearch: listSearchSchema,

  // loaderDepsで依存を明示
  loaderDeps: ({ search }) => ({
    page: search.page,
    search: search.search,
  }),

  loader: async ({ deps }) => {
    // depsが変更された時のみ再実行
    return api.items.list(deps)
  },
})

まとめ

TanStack Router v2は、型安全性とDXを重視した現代的なルーティングソリューションです。

主な利点:

  • エンドツーエンドの型安全性
  • ファイルベースルーティング
  • 検索パラメータの強力なバリデーション
  • React Queryとのシームレスな統合
  • 優れたDX(開発者体験)

適用シーン:

  • 大規模なSPAアプリケーション
  • 複雑な状態管理が必要な管理画面
  • 型安全性を重視するプロジェクト
  • React QueryやZodを既に使用している場合

React RouterやNext.jsと比較して、型安全性においては圧倒的なアドバンテージがあります。TypeScriptの恩恵を最大限に受けたいプロジェクトには最適な選択肢です。