TanStack Router完全ガイド - 型安全ルーティングの次世代標準


TanStack Router完全ガイド - 型安全ルーティングの次世代標準

Reactアプリケーション開発において、ルーティングは避けて通れない重要な要素です。従来のReact Routerは長年業界標準として利用されてきましたが、TypeScriptの型安全性やモダンな開発体験という点で課題を抱えていました。

TanStack Router(@tanstack/react-router)は、TanStack QueryやTanStack Tableを開発したTanner Linsleyによる次世代ルーターです。完全な型安全性、ファイルベースルーティング、優れたDX(開発者体験)、そして高いパフォーマンスを実現しています。

本記事では、TanStack Routerの導入から実践的な使い方、パフォーマンス最適化まで、実際のプロジェクトで使えるノウハウを徹底解説します。

TanStack Routerの特徴

完全な型安全性

TanStack Routerの最大の特徴は、ルート全体にわたる完全な型推論です。

// ルート定義から自動的に型が推論される
import { useNavigate, useParams, useSearch } from '@tanstack/react-router'

function ProductPage() {
  // paramsの型が自動推論される
  const { productId } = useParams({ from: '/products/$productId' })
  //    ^? string

  // 検索パラメータも型安全
  const search = useSearch({ from: '/products/' })
  //    ^? { category?: string; sort?: 'asc' | 'desc' }

  // ナビゲーションも型チェックされる
  const navigate = useNavigate()

  navigate({
    to: '/products/$productId',
    params: { productId: '123' }, // 型エラーが出る: string型が必要
    search: { category: 'electronics', sort: 'invalid' } // 型エラー
  })
}

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

Next.jsライクなファイルベースルーティングをサポートしています。

src/routes/
├── __root.tsx          # ルートレイアウト
├── index.tsx           # /
├── about.tsx           # /about
├── products/
│   ├── index.tsx       # /products
│   ├── $productId.tsx  # /products/:productId
│   └── $productId/
│       └── reviews.tsx # /products/:productId/reviews
└── _auth/              # レイアウトルート
    ├── login.tsx       # /login
    └── register.tsx    # /register

データローディングとプリフェッチ

TanStack Queryと統合し、データローディングとキャッシングを効率化します。

// ルート定義でデータローディングを宣言
export const Route = createFileRoute('/products/$productId')({
  loader: async ({ params }) => {
    return fetchProduct(params.productId)
  },
  // プリフェッチ設定
  preload: true,
  preloadMaxAge: 10000,
})

function ProductPage() {
  // ローダーの結果を型安全に取得
  const product = Route.useLoaderData()
  //    ^? Product
}

プロジェクトセットアップ

インストール

npm install @tanstack/react-router
npm install -D @tanstack/router-devtools @tanstack/router-vite-plugin

Vite設定

// 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',
    }),
  ],
})

ルートレイアウトの作成

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

export const Route = createRootRoute({
  component: RootComponent,
})

function RootComponent() {
  return (
    <>
      <nav>
        <Link to="/" activeProps={{ className: 'active' }}>
          Home
        </Link>
        <Link to="/products" activeProps={{ className: 'active' }}>
          Products
        </Link>
        <Link to="/about" activeProps={{ className: 'active' }}>
          About
        </Link>
      </nav>
      <main>
        <Outlet />
      </main>
      <TanStackRouterDevtools position="bottom-right" />
    </>
  )
}

アプリケーションエントリーポイント

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

// ルーターインスタンスの作成
const router = createRouter({
  routeTree,
  defaultPreload: 'intent', // ホバー時にプリフェッチ
})

// TypeScript型推論のためのグローバル型登録
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router
  }
}

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

ルート定義パターン

基本的なルート

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

export const Route = createFileRoute('/')({
  component: HomePage,
})

function HomePage() {
  return (
    <div>
      <h1>Welcome to TanStack Router</h1>
      <p>Type-safe routing for React applications</p>
    </div>
  )
}

動的ルート(パラメータ)

// src/routes/products/$productId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

// パラメータのバリデーションスキーマ
const productIdSchema = z.string().regex(/^\d+$/)

export const Route = createFileRoute('/products/$productId')({
  // パラメータバリデーション
  params: {
    parse: (params) => ({
      productId: productIdSchema.parse(params.productId),
    }),
    stringify: (params) => ({
      productId: params.productId,
    }),
  },
  // データローディング
  loader: async ({ params }) => {
    const product = await fetchProduct(params.productId)
    if (!product) {
      throw new Error('Product not found')
    }
    return { product }
  },
  component: ProductPage,
  errorComponent: ProductErrorPage,
  pendingComponent: () => <div>Loading product...</div>,
})

function ProductPage() {
  const { product } = Route.useLoaderData()
  const { productId } = Route.useParams()

  return (
    <div>
      <h1>{product.name}</h1>
      <p>Product ID: {productId}</p>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>
    </div>
  )
}

function ProductErrorPage({ error }: { error: Error }) {
  return (
    <div>
      <h1>Error loading product</h1>
      <p>{error.message}</p>
    </div>
  )
}

検索パラメータの型安全性

// src/routes/products/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'

// 検索パラメータのスキーマ定義
const productsSearchSchema = z.object({
  page: z.number().int().positive().optional().default(1),
  category: z.enum(['electronics', 'clothing', 'books']).optional(),
  sort: z.enum(['price-asc', 'price-desc', 'name']).optional().default('name'),
  q: z.string().optional(),
})

export const Route = createFileRoute('/products/')({
  validateSearch: productsSearchSchema,
  loaderDeps: ({ search }) => ({ search }),
  loader: async ({ deps: { search } }) => {
    const products = await fetchProducts({
      page: search.page,
      category: search.category,
      sort: search.sort,
      query: search.q,
    })
    return { products, totalPages: products.totalPages }
  },
  component: ProductsPage,
})

function ProductsPage() {
  const { products, totalPages } = Route.useLoaderData()
  const search = Route.useSearch()
  const navigate = Route.useNavigate()

  const handleFilterChange = (category: string) => {
    navigate({
      search: (prev) => ({
        ...prev,
        category: category as 'electronics' | 'clothing' | 'books',
        page: 1, // カテゴリ変更時はページをリセット
      }),
    })
  }

  const handlePageChange = (newPage: number) => {
    navigate({
      search: (prev) => ({ ...prev, page: newPage }),
    })
  }

  return (
    <div>
      <h1>Products</h1>

      {/* フィルター */}
      <div>
        <button onClick={() => handleFilterChange('electronics')}>
          Electronics
        </button>
        <button onClick={() => handleFilterChange('clothing')}>
          Clothing
        </button>
        <button onClick={() => handleFilterChange('books')}>
          Books
        </button>
      </div>

      {/* 商品リスト */}
      <div>
        {products.items.map((product) => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>

      {/* ページネーション */}
      <div>
        {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
          <button
            key={page}
            onClick={() => handlePageChange(page)}
            disabled={search.page === page}
          >
            {page}
          </button>
        ))}
      </div>
    </div>
  )
}

レイアウトルート

レイアウトルートは、複数のページで共通のUIを共有する場合に使用します。

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

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

function AuthLayout() {
  return (
    <div className="auth-layout">
      <div className="auth-sidebar">
        <img src="/logo.svg" alt="Logo" />
        <h2>Welcome Back</h2>
      </div>
      <div className="auth-content">
        <Outlet />
      </div>
    </div>
  )
}
// src/routes/_auth/login.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_auth/login')({
  component: LoginPage,
})

function LoginPage() {
  return (
    <form>
      <h1>Login</h1>
      <input type="email" placeholder="Email" />
      <input type="password" placeholder="Password" />
      <button type="submit">Sign In</button>
    </form>
  )
}

データローディングとキャッシング

ローダーの基本

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

export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    const [user, stats, notifications] = await Promise.all([
      fetchUser(),
      fetchStats(),
      fetchNotifications(),
    ])
    return { user, stats, notifications }
  },
  component: DashboardPage,
  pendingComponent: () => <div>Loading dashboard...</div>,
  pendingMs: 500, // 500ms未満の場合はローディング表示しない
  pendingMinMs: 1000, // 最低1秒間ローディングを表示
})

function DashboardPage() {
  const { user, stats, notifications } = Route.useLoaderData()

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <StatsGrid stats={stats} />
      <NotificationsList notifications={notifications} />
    </div>
  )
}

TanStack Queryとの統合

// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5分
      gcTime: 1000 * 60 * 10, // 10分(旧cacheTime)
    },
  },
})
// src/routes/posts/$postId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { queryClient } from '@/lib/queryClient'

const postQueryOptions = (postId: string) => ({
  queryKey: ['post', postId],
  queryFn: () => fetchPost(postId),
})

export const Route = createFileRoute('/posts/$postId')({
  loader: async ({ params }) => {
    // TanStack Queryのキャッシュを使用
    await queryClient.ensureQueryData(postQueryOptions(params.postId))
  },
  component: PostPage,
})

function PostPage() {
  const { postId } = Route.useParams()
  // useQueryでリアルタイム更新をサポート
  const { data: post, isLoading } = useQuery(postQueryOptions(postId))

  if (isLoading) return <div>Loading...</div>

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

データの再検証

// src/routes/posts/index.tsx
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/posts/')({
  loader: async () => {
    return { posts: await fetchPosts() }
  },
  // ルートに戻るたびにデータを再取得
  staleTime: 0,
  // またはカスタム条件で再検証
  shouldReload: ({ routeMatch }) => {
    const now = Date.now()
    const lastLoaded = routeMatch.updatedAt
    // 5分以上経過していたら再取得
    return now - lastLoaded > 1000 * 60 * 5
  },
  component: PostsPage,
})

ナビゲーションとリンク

型安全なナビゲーション

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

function Navigation() {
  const navigate = useNavigate()

  const handleProductClick = (productId: string) => {
    navigate({
      to: '/products/$productId',
      params: { productId },
      search: { tab: 'reviews' },
      // ナビゲーション時にスクロール位置をリセット
      resetScroll: true,
    })
  }

  return (
    <div>
      {/* 宣言的リンク */}
      <Link
        to="/products/$productId"
        params={{ productId: '123' }}
        search={{ tab: 'reviews' }}
        activeProps={{
          className: 'active',
          style: { fontWeight: 'bold' },
        }}
      >
        Product 123
      </Link>

      {/* プログラマティックナビゲーション */}
      <button onClick={() => handleProductClick('456')}>
        Go to Product 456
      </button>
    </div>
  )
}

プリフェッチ

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

function ProductCard({ product }: { product: Product }) {
  const prefetch = usePrefetch()

  const handleMouseEnter = () => {
    // ホバー時にプリフェッチ
    prefetch({
      to: '/products/$productId',
      params: { productId: product.id },
    })
  }

  return (
    <Link
      to="/products/$productId"
      params={{ productId: product.id }}
      onMouseEnter={handleMouseEnter}
    >
      <h3>{product.name}</h3>
      <p>{product.description}</p>
    </Link>
  )
}

検索パラメータの操作

import { useSearch, useNavigate } from '@tanstack/react-router'

function FilterBar() {
  const search = useSearch({ from: '/products/' })
  const navigate = useNavigate({ from: '/products/' })

  const updateFilter = (key: string, value: any) => {
    navigate({
      search: (prev) => ({
        ...prev,
        [key]: value,
        page: 1, // フィルター変更時はページをリセット
      }),
    })
  }

  const clearFilters = () => {
    navigate({
      search: {
        page: 1,
        sort: 'name', // デフォルト値
      },
    })
  }

  return (
    <div>
      <select
        value={search.category ?? ''}
        onChange={(e) => updateFilter('category', e.target.value || undefined)}
      >
        <option value="">All Categories</option>
        <option value="electronics">Electronics</option>
        <option value="clothing">Clothing</option>
        <option value="books">Books</option>
      </select>

      <select
        value={search.sort}
        onChange={(e) => updateFilter('sort', e.target.value)}
      >
        <option value="name">Name</option>
        <option value="price-asc">Price: Low to High</option>
        <option value="price-desc">Price: High to Low</option>
      </select>

      <button onClick={clearFilters}>Clear Filters</button>
    </div>
  )
}

認証とルート保護

認証状態の管理

// src/lib/auth.ts
import { createContext, useContext } from 'react'

interface AuthContext {
  user: User | null
  isAuthenticated: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => Promise<void>
}

const AuthContext = createContext<AuthContext | null>(null)

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider')
  }
  return context
}

保護されたルート

// src/routes/_authenticated.tsx
import { createFileRoute, redirect, Outlet } from '@tanstack/react-router'
import { getAuth } from '@/lib/auth'

export const Route = createFileRoute('/_authenticated')({
  beforeLoad: async ({ location }) => {
    const auth = getAuth()
    if (!auth.isAuthenticated) {
      throw redirect({
        to: '/login',
        search: {
          redirect: location.href,
        },
      })
    }
  },
  component: AuthenticatedLayout,
})

function AuthenticatedLayout() {
  return (
    <div>
      <AuthenticatedNav />
      <Outlet />
    </div>
  )
}
// src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router'
import { useAuth } from '@/lib/auth'

export const Route = createFileRoute('/_authenticated/dashboard')({
  loader: async () => {
    const auth = getAuth()
    const dashboard = await fetchDashboard(auth.user!.id)
    return { dashboard }
  },
  component: DashboardPage,
})

function DashboardPage() {
  const { user } = useAuth()
  const { dashboard } = Route.useLoaderData()

  return (
    <div>
      <h1>Dashboard - {user!.name}</h1>
      <DashboardContent data={dashboard} />
    </div>
  )
}

ログイン後のリダイレクト

// src/routes/login.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { z } from 'zod'

const loginSearchSchema = z.object({
  redirect: z.string().optional(),
})

export const Route = createFileRoute('/login')({
  validateSearch: loginSearchSchema,
  component: LoginPage,
})

function LoginPage() {
  const navigate = useNavigate()
  const search = Route.useSearch()
  const { login } = useAuth()

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)

    await login(
      formData.get('email') as string,
      formData.get('password') as string
    )

    // ログイン後、元のページまたはダッシュボードへリダイレクト
    navigate({
      to: search.redirect ?? '/dashboard',
    })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit">Login</button>
    </form>
  )
}

コード分割とレイジーローディング

ルートベースのコード分割

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

// コンポーネントを遅延ロード
const AdminPage = lazy(() => import('@/pages/AdminPage'))

export const Route = createFileRoute('/admin/')({
  component: AdminPage,
})

条件付きコード分割

// src/routes/editor/$documentId.tsx
import { createFileRoute } from '@tanstack/react-router'
import { lazy } from 'react'

const RichTextEditor = lazy(() => import('@/components/RichTextEditor'))
const CodeEditor = lazy(() => import('@/components/CodeEditor'))

export const Route = createFileRoute('/editor/$documentId')({
  loader: async ({ params }) => {
    const document = await fetchDocument(params.documentId)
    return { document }
  },
  component: EditorPage,
})

function EditorPage() {
  const { document } = Route.useLoaderData()

  // ドキュメントタイプに応じて異なるエディタをロード
  return (
    <Suspense fallback={<div>Loading editor...</div>}>
      {document.type === 'rich-text' ? (
        <RichTextEditor content={document.content} />
      ) : (
        <CodeEditor code={document.content} language={document.language} />
      )}
    </Suspense>
  )
}

エラーハンドリング

ルートレベルのエラーバウンダリ

// src/routes/products/$productId.tsx
import { createFileRoute, ErrorComponent } from '@tanstack/react-router'

export const Route = createFileRoute('/products/$productId')({
  loader: async ({ params }) => {
    const product = await fetchProduct(params.productId)
    if (!product) {
      throw new Error('Product not found')
    }
    return { product }
  },
  component: ProductPage,
  errorComponent: ProductErrorComponent,
})

function ProductErrorComponent({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h1>Failed to load product</h1>
      <p>{error.message}</p>
      <button onClick={reset}>Try Again</button>
    </div>
  )
}

グローバルエラーハンドリング

// src/routes/__root.tsx
import { createRootRoute, ErrorComponent } from '@tanstack/react-router'

export const Route = createRootRoute({
  component: RootComponent,
  errorComponent: RootErrorComponent,
})

function RootErrorComponent({ error }: { error: Error }) {
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{error.message}</p>
      <a href="/">Go Home</a>
    </div>
  )
}

Not Found ハンドリング

// src/routes/__root.tsx
export const Route = createRootRoute({
  component: RootComponent,
  notFoundComponent: NotFoundPage,
})

function NotFoundPage() {
  const router = useRouter()

  return (
    <div>
      <h1>404 - Page Not Found</h1>
      <p>The page "{router.state.location.pathname}" does not exist.</p>
      <Link to="/">Go Home</Link>
    </div>
  )
}

パフォーマンス最適化

プリロード設定

// src/main.tsx
const router = createRouter({
  routeTree,
  defaultPreload: 'intent', // デフォルトのプリロード戦略
  defaultPreloadDelay: 100, // ホバー後100msでプリロード開始
})

ルートごとのプリロード制御

// src/routes/products/$productId.tsx
export const Route = createFileRoute('/products/$productId')({
  loader: async ({ params }) => {
    return { product: await fetchProduct(params.productId) }
  },
  // このルートのプリロード設定
  preload: true,
  preloadMaxAge: 10000, // 10秒間キャッシュ
  component: ProductPage,
})

ローダーの最適化

// src/routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
  // 依存関係を明示的に宣言
  loaderDeps: ({ search }) => ({
    dateRange: search.dateRange,
  }),
  loader: async ({ deps }) => {
    // 依存関係が変わった場合のみ再実行
    const stats = await fetchStats(deps.dateRange)
    return { stats }
  },
  component: DashboardPage,
})

実践的なパターン

ブレッドクラム

// src/components/Breadcrumbs.tsx
import { useMatches, Link } from '@tanstack/react-router'

export function Breadcrumbs() {
  const matches = useMatches()

  const breadcrumbs = matches
    .filter((match) => match.context?.breadcrumb)
    .map((match) => ({
      title: match.context.breadcrumb,
      path: match.pathname,
    }))

  return (
    <nav>
      <Link to="/">Home</Link>
      {breadcrumbs.map((crumb, index) => (
        <span key={crumb.path}>
          {' / '}
          {index === breadcrumbs.length - 1 ? (
            <span>{crumb.title}</span>
          ) : (
            <Link to={crumb.path}>{crumb.title}</Link>
          )}
        </span>
      ))}
    </nav>
  )
}

// src/routes/products/$productId.tsx
export const Route = createFileRoute('/products/$productId')({
  loader: async ({ params }) => {
    const product = await fetchProduct(params.productId)
    return { product }
  },
  context: ({ loaderData }) => ({
    breadcrumb: loaderData.product.name,
  }),
  component: ProductPage,
})

ページタイトルの管理

// src/lib/useSeo.ts
import { useEffect } from 'react'
import { useRouter } from '@tanstack/react-router'

export function useSeo(title: string, description?: string) {
  const router = useRouter()

  useEffect(() => {
    document.title = `${title} | My App`

    if (description) {
      let metaDescription = document.querySelector('meta[name="description"]')
      if (!metaDescription) {
        metaDescription = document.createElement('meta')
        metaDescription.setAttribute('name', 'description')
        document.head.appendChild(metaDescription)
      }
      metaDescription.setAttribute('content', description)
    }
  }, [title, description])
}

// src/routes/products/$productId.tsx
function ProductPage() {
  const { product } = Route.useLoaderData()
  useSeo(product.name, product.description)

  return <div>{/* ... */}</div>
}

スクロール復元

// src/main.tsx
const router = createRouter({
  routeTree,
  defaultPreload: 'intent',
  // スクロール位置の復元
  scrollRestoration: 'smooth',
})

まとめ

TanStack Routerは、React向けの次世代型安全ルーターとして、以下の特徴を提供します。

  • 完全な型安全性: パラメータ、検索パラメータ、ナビゲーション全てが型推論される
  • ファイルベースルーティング: 直感的なフォルダ構造でルートを定義
  • 優れたDX: 自動コード生成とDevToolsで開発体験が向上
  • 高いパフォーマンス: プリフェッチとコード分割で最適化
  • TanStack Query統合: データローディングとキャッシングが効率的

従来のReact Routerと比較して、TypeScriptとの相性が格段に向上し、より保守しやすく、パフォーマンスの高いアプリケーションを構築できます。

新規プロジェクトではもちろん、既存プロジェクトでも段階的に移行することで、開発体験とアプリケーション品質を大きく改善できます。ぜひTanStack Routerを導入して、型安全なルーティングの恩恵を体験してください。