mcpField
T
tanstack-router-migration

tanstack-router-migration

github.com/redpanda-data開発4.3k63 回インストール今日更新
リポジトリ

tanstack-router-migration について

React Router から TanStack Router へアプリを移行する React 開発者向けのスキルです。段階的・漸進的な移行、ファイルベースのルート、型安全なフック、検索パラメーター検証、テストを扱います。

主な機能

  1. 01段階的な移行ワークフロー
  2. 02ファイルベースのルート構成
  3. 03型安全なフックとナビゲーション
  4. 04Zod による検索パラメーター検証
  5. 05テスト設定と移行チェック

活用シーン

  1. 01React Router 移行の計画
  2. 02ルートの段階的な移行
  3. 03フックと検索パラメーターの更新
  4. 04移行後のルートのテスト準備

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/redpanda-data/console --skill tanstack-router-migration

インストール後、Claude Code で/tanstack-router-migrationを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
build-configuration.md
incremental-migration.md
migration-patterns.md
route-templates.md

詳細

React Router から TanStack Router への移行

ファイルベースのルーティングを使用して、React アプリケーションを React Router から TanStack Router に移行します。このスキルでは、段階的な移行と一括移行の両方に対応した体系的な方法を紹介します。

重要なルール

必ず行うこと:

  • src/routes/ ディレクトリ内にルートを配置するファイルベースのルーティングを使用する
  • 型安全性を確保するため、すべてのフックで from パラメーターを使用する(useParams({ from: '/path' }))
  • @tanstack/zod-adapter を使用した Zod スキーマで検索パラメーターを検証する
  • ルートを作成する前にビルドツールのプラグインを設定する
  • TypeScript の推論を完全に有効にするため、ルーターの型を登録する
  • 省略可能な検索パラメーターには fallback() ラッパーを使用する

絶対に行わないこと:

  • routeTree.gen.ts を編集する(自動生成ファイルです)
  • 移行中に新しいコードで React Router のフックを使用する
  • from パラメーターを省略する(型安全性が失われます)
  • 検索パラメーターの検証に文字列のみの検証を使用する
  • ビルドプラグインの設定を省略する

依存関係

# Core dependencies
bun add @tanstack/react-router @tanstack/zod-adapter

# Build plugin (choose one based on your bundler)
bun add -d @tanstack/router-plugin

# Optional integrations
bun add nuqs                    # URL state management
bun add @sentry/react           # Error tracking with router integration

移行フェーズ

フェーズ 1:評価

既存の React Router の使用状況を監査します:

# Find all React Router imports
grep -r "from 'react-router" src/ --include="*.tsx" --include="*.ts"
grep -r 'from "react-router' src/ --include="*.tsx" --include="*.ts"

# Find hook usages
grep -r "useParams\|useSearchParams\|useNavigate\|useLocation\|useMatch" src/

以下を記録します:

  • React Router のバージョン(v5 または v6)
  • ルート数
  • useParams の使用回数
  • useSearchParams の使用回数
  • useNavigate の使用回数
  • カスタム Link コンポーネント
  • ルートガード/保護されたルート
  • 既存のルート構成
フェーズ 2:セットアップ

1. ビルドツールを設定する

設定全体については、references/build-configuration.md を参照してください。

Rspack/Rsbuild:

// rsbuild.config.ts
import { TanStackRouterRspack } from '@tanstack/router-plugin/rspack';

export default {
  tools: {
    rspack: (config) => {
      config.plugins?.push(
        TanStackRouterRspack({
          target: 'react',
          autoCodeSplitting: true,
          routesDirectory: './src/routes',
          generatedRouteTree: './src/routeTree.gen.ts',
          quoteStyle: 'single',
          semicolons: true,
        })
      );
      // Prevent rebuild loop
      config.watchOptions = { ignored: ['**/routeTree.gen.ts'] };
      return config;
    },
  },
};

Vite:

// vite.config.ts
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';

export default defineConfig({
  plugins: [
    TanStackRouterVite({
      target: 'react',
      autoCodeSplitting: true,
      routesDirectory: './src/routes',
      generatedRouteTree: './src/routeTree.gen.ts',
    }),
    react(),
  ],
});

2. リンターを設定する

// biome.jsonc or eslint config
{
  "files": {
    "ignore": ["**/routeTree.gen.ts"]
  },
  "overrides": [
    {
      "include": ["**/routes/**/*"],
      "linter": {
        "rules": {
          "style": {
            "useFilenamingConvention": "off"  // Allow $param.tsx naming
          }
        }
      }
    }
  ]
}

3. ルートディレクトリを作成する

mkdir -p src/routes
フェーズ 3:ルーターの作成

ルーターインスタンスを作成する:

// src/app.tsx
import { createRouter, RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { routeTree } from './routeTree.gen';
import { NotFoundPage } from './components/misc/not-found-page';

const queryClient = new QueryClient();

const router = createRouter({
  routeTree,
  context: {
    basePath: getBasePath(),
    queryClient,
  },
  basepath: getBasePath(),
  trailingSlash: 'never',
  defaultNotFoundComponent: NotFoundPage,
});

// Register router type for full TypeScript inference
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }

  // Extend HistoryState for typed navigation state
  interface HistoryState {
    // Add your custom state properties here
    returnUrl?: string;
    documentId?: string;
    documentName?: string;
  }
}

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}

ルーターのコンテキスト型を定義する:

// src/routes/__root.tsx
import type { QueryClient } from '@tanstack/react-query';

export type RouterContext = {
  basePath: string;
  queryClient: QueryClient;
};
フェーズ 4:ルートの移行

ルートレイアウトを作成する:

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

export type RouterContext = {
  basePath: string;
  queryClient: QueryClient;
};

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

function RootLayout() {
  return (
    <>
      <NuqsAdapter>
        <ErrorBoundary>
          <AppLayout>
            <Outlet />
          </AppLayout>
        </ErrorBoundary>
      </NuqsAdapter>
      {process.env.NODE_ENV === 'development' && (
        <TanStackRouterDevtools position="bottom-right" />
      )}
    </>
  );
}

ファイルベースのルート構成:

src/routes/
├── __root.tsx                    # Root layout
├── index.tsx                     # / (root redirect)
├── overview/
│   └── index.tsx                 # /overview
├── topics/
│   ├── index.tsx                 # /topics
│   └── $topicName/
│       ├── index.tsx             # /topics/:topicName
│       └── edit.tsx              # /topics/:topicName/edit
├── security/
│   ├── index.tsx                 # /security (redirect)
│   ├── acls/
│   │   ├── index.tsx             # /security/acls
│   │   ├── create.tsx            # /security/acls/create
│   │   └── $aclName/
│   │       └── details.tsx       # /security/acls/:aclName/details

完全なテンプレートについては、references/route-templates.md を参照してください。

フェーズ 5:フックの移行
React RouterTanStack Router
useParams()useParams({ from: '/path/$param' })
useSearchParams()Zod 検証を使った routeApi.useSearch()
useNavigate()useNavigate({ from: '/path' })
useLocation()useLocation()(同じ API)
<Link to="/path"><Link to="/path">(型安全)
<Navigate to="/path" /><Navigate to="/path" />

変更前と変更後の詳しい例については、references/migration-patterns.md を参照してください。

ナビゲーション状態:

HistoryState を使用して、型付けされた状態をルート間で渡します:

// Navigating with state
const navigate = useNavigate();
navigate({
  to: '/documents/$documentId',
  params: { documentId },
  state: {
    returnUrl: location.pathname,
    documentName: 'My Document',
  },
});

// Reading state in destination component
import { useLocation } from '@tanstack/react-router';

function DocumentPage() {
  const location = useLocation();
  const { returnUrl, documentName } = location.state;
  // Use state values...
}

useParams の移行:

// Before (React Router)
import { useParams } from 'react-router-dom';
const { id } = useParams<{ id: string }>();

// After (TanStack Router)
import { useParams } from '@tanstack/react-router';
const { id } = useParams({ from: '/items/$id' });

Zod 検証を使った useSearch:

// In route file
import { fallback, zodValidator } from '@tanstack/zod-adapter';
import { z } from 'zod';

const searchSchema = z.object({
  tab: fallback(z.string().optional(), undefined),
  page: fallback(z.number().optional(), 1),
  q: fallback(z.string().optional(), undefined),
});

export const Route = createFileRoute('/items/')({
  validateSearch: zodValidator(searchSchema),
  component: ItemsPage,
});

// In component
import { getRouteApi, useNavigate } from '@tanstack/react-router';

const routeApi = getRouteApi('/items/');

function ItemsPage() {
  const { tab, page, q } = routeApi.useSearch();
  const navigate = useNavigate({ from: '/items/' });

  const handleTabChange = (newTab: string) => {
    navigate({ search: (prev) => ({ ...prev, tab: newTab }) });
  };
}
フェーズ 6:テスト

テストユーティリティを作成する:

// src/test-utils.tsx
import { createMemoryHistory, createRouter, RouterProvider } from '@tanstack/react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, type RenderOptions } from '@testing-library/react';
import { routeTree } from './routeTree.gen';
import type { RouterContext } from './routes/__root';

interface RenderWithFileRoutesOptions extends Omit<RenderOptions, 'wrapper'> {
  initialLocation?: string;
  routerContext?: Partial<RouterContext>;
}

export function renderWithFileRoutes(
  ui: React.ReactElement | null = null,
  { initialLocation = '/', routerContext = {}, ...renderOptions }: RenderWithFileRoutesOptions = {}
) {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  const router = createRouter({
    routeTree,
    history: createMemoryHistory({ initialEntries: [initialLocation] }),
    context: { basePath: '', queryClient, ...routerContext },
  });

  function Wrapper({ children }: { children: React.ReactNode }) {
    return (
      <QueryClientProvider client={queryClient}>
        <RouterProvider router={router}>{children}</RouterProvider>
      </QueryClientProvider>
    );
  }

  return {
    ...render(ui ?? <div />, { wrapper: Wrapper, ...renderOptions }),
    router,
  };
}

export async function renderRoute(location: string, options?: RenderWithFileRoutesOptions) {
  const result = renderWithFileRoutes(null, { initialLocation: location, ...options });
  await result.router.load();
  return result;
}

Vitest を設定する:

// vitest.config.integration.mts
import { tanstackRouter } from '@tanstack/router-plugin/vite';

export default defineConfig({
  plugins: [
    tanstackRouter({
      target: 'react',
      routesDirectory: './src/routes',
      generatedRouteTree: './src/routeTree.gen.ts',
    }),
    react(),
  ],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
  },
});
フェーズ 7:統合

Sentry の統合:

// src/app.tsx
import * as Sentry from '@sentry/react';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  integrations: [
    Sentry.tanstackRouterBrowserTracingIntegration(router),
  ],
  tracesSampleRate: 1.0,
});

nuqs の統合:

// src/routes/__root.tsx
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';

function RootLayout() {
  return (
    <NuqsAdapter>
      <Outlet />
    </NuqsAdapter>
  );
}

段階的な移行(レガシーとの互換性):

移行中に両方のルーターを併用するパターンについては、references/incremental-migration.md を参照してください。

クイックリファレンス

ルートファイルの命名
パターンファイルURL
インデックスルートtopics/index.tsx/topics
動的パラメーターtopics/$topicName.tsx/topics/:topicName
ネストされた動的ルートtopics/$topicName/edit.tsx/topics/:topicName/edit
パスレスレイアウト_layout.tsx(URL セグメントなし)
キャッチオール$.tsx/*
よく使う Zod パターン
import { fallback, zodValidator } from '@tanstack/zod-adapter';
import { z } from 'zod';

const searchSchema = z.object({
  // Optional string with undefined default
  tab: fallback(z.string().optional(), undefined),

  // Optional number with default value
  page: fallback(z.number().optional(), 1),

  // Required string
  id: z.string(),

  // Enum with default
  sort: fallback(z.enum(['asc', 'desc']).optional(), 'asc'),

  // Boolean
  expanded: fallback(z.boolean().optional(), false),
});
from パラメーターの末尾のスラッシュ

from パラメーターは、定義されたルートパスと完全に一致させる必要があります:

// Index routes (files named index.tsx) include trailing slash:
useParams({ from: '/topics/$topicName/' })  // Route: topics/$topicName/index.tsx

// Non-index routes do NOT include trailing slash:
useParams({ from: '/topics/$topicName/edit' })  // Route: topics/$topicName/edit.tsx
型安全なナビゲーション
// With params
<Link to="/topics/$topicName" params={{ topicName: 'my-topic' }}>
  View Topic
</Link>

// With search params
<Link to="/topics" search={{ page: 2, sort: 'desc' }}>
  Page 2
</Link>

// Programmatic navigation
const navigate = useNavigate({ from: '/topics/$topicName' });
navigate({
  to: '/topics/$topicName/edit',
  params: { topicName },
  search: { tab: 'settings' },
});

チェックリスト

移行前
  • 依存関係をインストール済み(@tanstack/react-router、@tanstack/router-plugin、@tanstack/zod-adapter)
  • ビルドツールのプラグインを設定済み
  • $param.tsx 形式の命名を許可するようリンターを設定済み
  • src/routes/ ディレクトリを作成済み
ルートの移行
  • プロバイダーとレイアウトを含む __root.tsx を作成
  • ルートリダイレクト用の index.tsx を作成
  • すべてのルートをファイルベースの構成に移行
  • 検索パラメーターを Zod スキーマで検証
  • タイトル/アイコン用の staticData を追加
フックの移行
  • すべての useParams 呼び出しを from パラメーター付きに更新
  • すべての useSearchParams を routeApi.useSearch() に置き換え
  • すべての useNavigate 呼び出しを from パラメーター付きに更新
  • すべての Link コンポーネントの動作を確認
テスト
  • renderWithFileRoutes ユーティリティを作成
  • TanStack Router プラグインを使用するように Vitest を設定
  • 新しいユーティリティを使用するように既存のテストを更新
インテグレーション
  • Sentry インテグレーションを設定(使用している場合)
  • nuqs アダプターをルートレイアウトでラップ(使用している場合)
クリーンアップ(完全移行後)
  • React Router の依存関係を削除
  • レガシーなルート定義を削除
  • BrowserRouter ラッパーを削除
  • RouterSync コンポーネントを削除

よくある落とし穴

  1. from パラメーターの指定漏れ - 型安全性を確保するため、フックでは常に from を指定してください
  2. fallback() ラッパーの付け忘れ - オプションの検索パラメーターには fallback(z.string().optional(), undefined) が必要です
  3. 末尾のスラッシュの不統一 - trailingSlash: 'never' を設定し、一貫性を保ってください
  4. routeTree.gen.ts の編集 - ファイルの変更時に自動生成されるため、編集しないでください
  5. ビルドプラグインの設定漏れ - バンドラープラグインがないとルートは生成されません
  6. 非同期ナビゲーションの警告 - navigate() は Promise を返します。void navigate() を使用するか、await してください
  7. セクションのリダイレクトに <Navigate> を使用 - 埋め込みモードでのナビゲーションループを防ぐため、代わりに beforeLoad で throw redirect() を使用してください:
    beforeLoad: () => {
      throw redirect({ to: '/section/$tab', params: { tab: 'default' }, replace: true });
    }
    
  8. インデックスルートの from パラメーターに末尾のスラッシュがない - インデックスルート(ファイル名が index.tsx のルート)では、from に末尾のスラッシュが必要です:
    // Index route: /topics/$topicName/index.tsx
    useParams({ from: '/topics/$topicName/' })  // ✅ Correct (trailing slash)
    useParams({ from: '/topics/$topicName' })   // ❌ Wrong
    
  9. HistoryState 拡張の設定漏れ - 型付きのナビゲーション状態を使用するには、HistoryState インターフェースを拡張してください(フェーズ 3 を参照)

ドキュメント

コメント