React Router

React Router 完全ガイド:モダンReactアプリケーションのルーティング戦略と実践

対象読者: React の基礎知識を持つフロントエンド開発者
対応バージョン: React Router v6.4+ / v7
最終更新: 2026年4月


1. はじめに

1.1 React Router とは何か

React Router は、React アプリケーションにおけるクライアントサイドルーティングを実現するための標準的なライブラリである。ブラウザの URL とアプリケーションの UI を同期させ、ユーザーがページ遷移を行う際にフルページリロードを発生させることなく、シームレスなナビゲーション体験を提供する。

React 自体はルーティング機能を内蔵しておらず、コンポーネントの描画とステート管理に特化している。そのため、複数画面を持つアプリケーションを構築する場合、ルーティングライブラリの導入が不可欠となる。React Router は 2014 年の初回リリース以来、React エコシステムにおけるデファクトスタンダードの地位を維持し続けており、npm での週間ダウンロード数は 1,000 万を超える。

// React Router の最も基本的な使用例
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <HomePage />,
  },
  {
    path: '/about',
    element: <AboutPage />,
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

1.2 バージョンの歴史と進化

React Router の歴史は、Web フロントエンド開発の変遷そのものを映し出している。

バージョンリリース年主な特徴
v12015React コンポーネントベースの宣言的ルーティング導入
v22016パフォーマンス改善、<IndexRoute> の追加
v32016v2 の安定版、API の成熟
v42017大幅な設計変更 — 動的ルーティング、react-router-dom 分離
v52019v4 の改良版、hooks API(useHistory, useLocation 等)導入
v62021再設計<Routes> 導入、相対パス、サイズ削減
v6.42022Data APIsloader, action, createBrowserRouter 導入
v72025フレームワークモード — Remix との統合、RSC サポート

v4 での大転換は特に注目に値する。v3 まではルート定義を一箇所に集中させる静的ルーティングが主流だったが、v4 ではコンポーネントツリー内の任意の場所でルートを定義できる動的ルーティングへと移行した。

// v3 以前: 静的ルーティング(集中定義)
<Router>
  <Route path="/" component={App}>
    <Route path="users" component={Users} />
    <Route path="users/:id" component={UserProfile} />
  </Route>
</Router>

// v4+: 動的ルーティング(コンポーネント内で分散定義)
function App() {
  return (
    <div>
      <nav>...</nav>
      <Routes>
        <Route path="/users" element={<Users />} />
        <Route path="/users/:id" element={<UserProfile />} />
      </Routes>
    </div>
  );
}

v6.4 での Data APIs 導入は、もう一つの大きな転換点であった。Remix フレームワークで培われたデータローディングパターンが React Router に逆輸入され、loaderaction によるデータフェッチの宣言的なモデルが導入された。

1.3 Remix との関係

React Router と Remix の関係を理解することは、現在の React Router を正しく活用するうえで極めて重要である。

Remix は React Router の開発チーム(Ryan Florence と Michael Jackson)が 2020 年に立ち上げたフルスタック Web フレームワークである。Remix は React Router をベースに、サーバーサイドレンダリング(SSR)、データローディング、フォーム処理などのフレームワーク機能を追加したものだった。

2022 年に Shopify が Remix を買収した後、開発チームは大きな決断を下した。Remix の優れたパターン(loaderactionForm コンポーネント等)を React Router 本体に統合し、最終的に Remix と React Router を一つのプロジェクトに収束させるという方針である。

React Router v5 → React Router v6 → React Router v6.4 (Data APIs) → React Router v7
                                          ↑                              ↑
                                    Remix のパターンを逆輸入        Remix と統合完了

React Router v7 では、以下の 2 つのモードが提供されている:

  • ライブラリモード: 従来の React Router と同様、クライアントサイドルーティングのみを提供
  • フレームワークモード: Remix の後継として、SSR・ファイルベースルーティング・サーバー機能を提供
// ライブラリモード: 従来どおりの使い方
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

// フレームワークモード: Remix 的な使い方(react-router.config.ts で設定)
// routes/home.tsx
export async function loader() {
  const data = await fetchData();
  return { data };
}

export default function Home({ loaderData }: { loaderData: Data }) {
  return <div>{/* ... */}</div>;
}

1.4 インストールと初期セットアップ

React Router の導入は非常にシンプルである。

# ライブラリモードでの導入
npm install react-router-dom

# フレームワークモードでの新規プロジェクト作成
npx create-react-router@latest my-app

基本的なプロジェクトのセットアップは以下のようになる:

// src/main.tsx — アプリケーションのエントリーポイント
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { RootLayout } from './layouts/RootLayout';
import { HomePage } from './pages/HomePage';
import { AboutPage } from './pages/AboutPage';
import { NotFoundPage } from './pages/NotFoundPage';

// ルート定義(v6.4+ 推奨パターン)
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <NotFoundPage />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'about', element: <AboutPage /> },
    ],
  },
]);

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <RouterProvider router={router} />
  </React.StrictMode>
);

2. コアコンセプト

2.1 クライアントサイドルーティング vs サーバーサイドルーティング

Web アプリケーションにおけるルーティングには、大きく分けてサーバーサイドルーティングとクライアントサイドルーティングの 2 つのアプローチがある。

サーバーサイドルーティング(従来型) では、ユーザーがリンクをクリックするたびにブラウザがサーバーへ新たな HTTP リクエストを送信し、サーバーが完全な HTML ドキュメントを返却する。ページ全体がリロードされるため、ユーザーは一瞬の空白画面を経験する。

クライアントサイドルーティング(SPA) では、最初のページロード時に JavaScript アプリケーション全体がダウンロードされ、以降のナビゲーションはブラウザ内で JavaScript が URL の変更を検知し、対応するコンポーネントを描画する。サーバーへのリクエストはデータ取得のための API コールのみとなる。

// サーバーサイドルーティング(従来の HTML)
// → クリックするとページ全体がリロードされる
<a href="/about">About</a>

// クライアントサイドルーティング(React Router)
// → JavaScript が URL を変更し、コンポーネントを切り替える
import { Link } from 'react-router-dom';
<Link to="/about">About</Link>

React Router は内部的に History API を使用している。history.pushState()popstate イベントを活用し、ブラウザの URL バーを更新しつつ、ページのリロードを抑制する。

// React Router が内部的に行っていることの簡略化
// (実際にはもっと複雑だが、概念理解のために)

// URL を変更(ブラウザのアドレスバーが更新される)
window.history.pushState({}, '', '/about');

// 戻る/進むボタンの検知
window.addEventListener('popstate', () => {
  const currentPath = window.location.pathname;
  // currentPath に応じたコンポーネントを描画
  renderComponentForPath(currentPath);
});

2.2 URL をアプリケーションの状態として扱う

React Router の重要な哲学の一つは、URL をアプリケーションの状態の一部として扱うことである。URL はユーザーがアプリケーション内のどの位置にいるかを表すだけでなく、フィルター条件、ソート順、ページネーション情報などの状態を保持できる。

// URL: /products?category=electronics&sort=price&page=2
// URL がアプリケーションの状態を保持している

import { useSearchParams } from 'react-router-dom';

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  // URL からアプリケーション状態を復元
  const category = searchParams.get('category') || 'all';
  const sort = searchParams.get('sort') || 'name';
  const page = parseInt(searchParams.get('page') || '1', 10);

  // 状態変更時に URL も更新
  const handleCategoryChange = (newCategory: string) => {
    setSearchParams((prev) => {
      prev.set('category', newCategory);
      prev.set('page', '1'); // カテゴリ変更時はページをリセット
      return prev;
    });
  };

  return (
    <div>
      <CategoryFilter value={category} onChange={handleCategoryChange} />
      <SortSelector value={sort} onChange={(s) => setSearchParams(prev => { prev.set('sort', s); return prev; })} />
      <ProductGrid category={category} sort={sort} page={page} />
      <Pagination page={page} />
    </div>
  );
}

URL を状態として活用するメリットは以下のとおりである:

メリット説明
ブックマーク可能ユーザーが特定の状態をブックマークし、後で同じ状態に戻れる
共有可能URL をコピーして他のユーザーに同じ画面を共有できる
戻る/進む対応ブラウザの戻る/進むボタンが自然に動作する
SEO フレンドリー検索エンジンが各ページをクロール・インデックスできる
デバッグ容易URL を見るだけでアプリケーションの現在の状態を把握できる

2.3 宣言的ルーティング

React Router は宣言的(Declarative)ルーティングを採用している。「この URL パスの場合は、このコンポーネントを表示する」という対応関係を宣言的に記述する。手続き的に「URL が変わったら、ここに遷移して、このコンポーネントをマウントして...」と記述する必要はない。

// 宣言的ルーティング: 「何を表示するか」を記述する
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      { path: 'dashboard', element: <Dashboard /> },
      {
        path: 'users',
        element: <UsersLayout />,
        children: [
          { index: true, element: <UsersList /> },
          { path: ':userId', element: <UserDetail /> },
        ],
      },
    ],
  },
]);

// 手続き的ルーティング(非推奨パターン): 「どう遷移するか」を記述する
// このようなパターンは React Router では不要
if (location.pathname === '/') {
  render(<Home />);
} else if (location.pathname.startsWith('/users/')) {
  const userId = location.pathname.split('/')[2];
  render(<UserDetail userId={userId} />);
}

2.4 ルーターの種類

React Router は複数のルーター実装を提供しており、用途に応じて使い分ける。

import {
  createBrowserRouter,    // 本番環境で使用(推奨)
  createHashRouter,       // 静的ファイルホスティング用
  createMemoryRouter,     // テスト・SSR 用
  createStaticRouter,     // サーバーサイドレンダリング用
} from 'react-router-dom';
ルーターURL 形式用途
createBrowserRouter/about, /users/123標準的な Web アプリケーション
createHashRouter/#/about, /#/users/123GitHub Pages 等の静的ホスティング
createMemoryRouterメモリ内(URL バーに反映されない)テスト、React Native、Electron
createStaticRouterサーバー側で使用SSR(サーバーサイドレンダリング)
// BrowserRouter: HTML5 History API を使用
// 最も一般的な選択肢。サーバー側で SPA フォールバック設定が必要
const browserRouter = createBrowserRouter([
  { path: '/', element: <Home /> },
  { path: '/about', element: <About /> },
]);

// HashRouter: URL のハッシュ部分を使用
// サーバー設定が不要だが、SEO には不利
const hashRouter = createHashRouter([
  { path: '/', element: <Home /> },
  { path: '/about', element: <About /> },
]);

// MemoryRouter: テストで最も活躍する
import { createMemoryRouter } from 'react-router-dom';

const memoryRouter = createMemoryRouter(
  [{ path: '/test', element: <TestComponent /> }],
  { initialEntries: ['/test'] } // 初期 URL を指定
);

3. 基本的なルーティング

3.1 BrowserRouter と RouterProvider

React Router v6.4 以降では、ルーターの作成方法が 2 つある。新規プロジェクトでは Data APIs をフル活用できる createBrowserRouter + RouterProvider パターンが推奨される。

// パターン 1: createBrowserRouter + RouterProvider(推奨)
// Data APIs(loader, action)が使える
import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'about', element: <AboutPage /> },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}
// パターン 2: BrowserRouter + Routes(レガシー互換)
// Data APIs は使えないが、既存プロジェクトの移行に便利
import { BrowserRouter, Routes, Route } from 'react-router-dom';

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<RootLayout />}>
          <Route index element={<HomePage />} />
          <Route path="about" element={<AboutPage />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

3.2 Route コンポーネント

Route コンポーネントは、URL パスとレンダリングするコンポーネントの対応関係を定義する。

import { createBrowserRouter } from 'react-router-dom';

const router = createBrowserRouter([
  {
    // ルートパス
    path: '/',
    // このパスにマッチした時に描画するコンポーネント
    element: <Layout />,
    // エラー発生時に描画するコンポーネント
    errorElement: <ErrorPage />,
    // 子ルート(Layout の <Outlet /> に描画される)
    children: [
      {
        // index ルート: 親パスに完全一致した場合に描画
        index: true,
        element: <HomePage />,
      },
      {
        // 静的パス
        path: 'about',
        element: <AboutPage />,
      },
      {
        // 動的セグメント: ':' プレフィックスで動的パラメータを定義
        path: 'users/:userId',
        element: <UserProfilePage />,
        // このルートのデータローダー(Data APIs)
        loader: userLoader,
      },
      {
        // ネストされたルート
        path: 'settings',
        element: <SettingsLayout />,
        children: [
          { index: true, element: <SettingsGeneral /> },
          { path: 'profile', element: <SettingsProfile /> },
          { path: 'notifications', element: <SettingsNotifications /> },
        ],
      },
    ],
  },
]);

3.3 Link と NavLink

<Link> はクライアントサイドナビゲーションを行うための基本コンポーネントである。通常の <a> タグの代わりに使用し、ページリロードを防止する。

import { Link, NavLink } from 'react-router-dom';

function Navigation() {
  return (
    <nav>
      {/* 基本的なリンク */}
      <Link to="/">ホーム</Link>

      {/* 相対パスリンク(現在のパスからの相対位置) */}
      <Link to="settings">設定</Link>

      {/* 絶対パスリンク */}
      <Link to="/users/123">ユーザー詳細</Link>

      {/* state を渡すリンク */}
      <Link to="/login" state={{ from: '/dashboard' }}>
        ログイン
      </Link>

      {/* 置換ナビゲーション(履歴に追加せず置換) */}
      <Link to="/new-page" replace>
        移動(履歴に残さない)
      </Link>

      {/* クエリパラメータ付きリンク */}
      <Link to="/search?q=react&page=1">検索</Link>

      {/* オブジェクト形式での指定 */}
      <Link
        to={{
          pathname: '/search',
          search: '?q=react&page=1',
          hash: '#results',
        }}
      >
        詳細検索
      </Link>
    </nav>
  );
}

<NavLink><Link> を拡張したコンポーネントで、現在のルートとマッチしているかどうかに応じてスタイルや CSS クラスを動的に適用できる。ナビゲーションバーやサイドバーのメニューに最適である。

import { NavLink } from 'react-router-dom';

function Sidebar() {
  return (
    <nav className="sidebar">
      {/* アクティブ状態に応じてクラス名を動的に変更 */}
      <NavLink
        to="/"
        className={({ isActive, isPending }) =>
          isActive ? 'nav-link active' : isPending ? 'nav-link pending' : 'nav-link'
        }
      >
        ホーム
      </NavLink>

      {/* アクティブ状態に応じてスタイルを動的に変更 */}
      <NavLink
        to="/dashboard"
        style={({ isActive }) => ({
          fontWeight: isActive ? 'bold' : 'normal',
          color: isActive ? '#0071e3' : '#1d1d1f',
        })}
      >
        ダッシュボード
      </NavLink>

      {/* end プロパティ: 完全一致の場合のみアクティブにする */}
      {/* /users と /users/123 の両方でアクティブになることを防ぐ */}
      <NavLink to="/users" end>
        ユーザー一覧
      </NavLink>

      {/* 子要素を関数として渡すパターン */}
      <NavLink to="/reports">
        {({ isActive, isPending, isTransitioning }) => (
          <span className={isActive ? 'active' : ''}>
            {isPending && <Spinner />}
            レポート
          </span>
        )}
      </NavLink>
    </nav>
  );
}

3.4 Navigate コンポーネント

<Navigate> は宣言的にリダイレクトを行うためのコンポーネントである。レンダリングされると自動的にナビゲーションを実行する。

import { Navigate } from 'react-router-dom';

// 例 1: 未認証ユーザーのリダイレクト
function ProtectedPage() {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    // ログインページにリダイレクト(現在の URL を state に保存)
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }

  return <div>保護されたコンテンツ</div>;
}

// 例 2: 旧 URL からのリダイレクト
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      // 旧 URL を新 URL にリダイレクト
      { path: 'old-path', element: <Navigate to="/new-path" replace /> },
      { path: 'new-path', element: <NewPage /> },

      // デフォルトルートのリダイレクト
      { index: true, element: <Navigate to="/dashboard" replace /> },
    ],
  },
]);

3.5 Outlet — ネストされたルートの描画

<Outlet> は、親ルートコンポーネント内で子ルートのコンポーネントを描画するためのプレースホルダーである。ネストされたルーティングの要となるコンポーネントだ。

import { Outlet, Link } from 'react-router-dom';

// 親レイアウトコンポーネント
function RootLayout() {
  return (
    <div className="app">
      <header>
        <nav>
          <Link to="/">ホーム</Link>
          <Link to="/about">About</Link>
          <Link to="/contact">Contact</Link>
        </nav>
      </header>

      <main>
        {/* 子ルートのコンポーネントがここに描画される */}
        <Outlet />
      </main>

      <footer>
        <p>&copy; 2026 My App</p>
      </footer>
    </div>
  );
}

// Outlet に context を渡すこともできる
function DashboardLayout() {
  const [notifications, setNotifications] = useState<Notification[]>([]);

  return (
    <div className="dashboard">
      <DashboardSidebar />
      <div className="dashboard-content">
        {/* context プロパティで子ルートにデータを渡す */}
        <Outlet context={{ notifications, setNotifications }} />
      </div>
    </div>
  );
}

// 子コンポーネントで context を受け取る
import { useOutletContext } from 'react-router-dom';

interface DashboardContext {
  notifications: Notification[];
  setNotifications: (n: Notification[]) => void;
}

function DashboardHome() {
  const { notifications } = useOutletContext<DashboardContext>();
  return <div>通知: {notifications.length} 件</div>;
}

3.6 ネストされたルートの実践例

ネストされたルーティングは React Router の最も強力な機能の一つである。レイアウトの共有と URL 構造の階層化を自然に実現できる。

// 完全なネストルーティングの例
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,      // ヘッダー + フッター + <Outlet />
    errorElement: <RootError />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'about', element: <AboutPage /> },

      // /dashboard 以下のルート群
      {
        path: 'dashboard',
        element: <DashboardLayout />,  // サイドバー + <Outlet />
        children: [
          { index: true, element: <DashboardHome /> },
          { path: 'analytics', element: <Analytics /> },
          {
            path: 'projects',
            element: <ProjectsLayout />,  // さらにネスト
            children: [
              { index: true, element: <ProjectsList /> },
              { path: ':projectId', element: <ProjectDetail /> },
              { path: ':projectId/edit', element: <ProjectEdit /> },
            ],
          },
        ],
      },
    ],
  },
]);

// URL → コンポーネントの対応関係:
// /                        → RootLayout > HomePage
// /about                   → RootLayout > AboutPage
// /dashboard               → RootLayout > DashboardLayout > DashboardHome
// /dashboard/analytics     → RootLayout > DashboardLayout > Analytics
// /dashboard/projects      → RootLayout > DashboardLayout > ProjectsLayout > ProjectsList
// /dashboard/projects/42   → RootLayout > DashboardLayout > ProjectsLayout > ProjectDetail

4. ルートの設計パターン

4.1 レイアウトルート

レイアウトルートは、自身のパスセグメントを URL に追加せず、共通レイアウトを子ルートに提供するパターンである。

const router = createBrowserRouter([
  {
    // レイアウトルート: path を持つが、子ルートの共通レイアウトを定義
    path: '/',
    element: <AppLayout />,
    children: [
      { index: true, element: <Home /> },

      // パスレスレイアウトルート: path がない
      // 認証が必要なページグループに共通のレイアウトを適用
      {
        element: <AuthenticatedLayout />,
        children: [
          { path: 'dashboard', element: <Dashboard /> },
          { path: 'profile', element: <Profile /> },
          { path: 'settings', element: <Settings /> },
        ],
      },

      // 別のパスレスレイアウト: 管理者ページグループ
      {
        element: <AdminLayout />,
        children: [
          { path: 'admin', element: <AdminDashboard /> },
          { path: 'admin/users', element: <AdminUsers /> },
        ],
      },
    ],
  },
]);
// AuthenticatedLayout: 認証チェック付きレイアウト
function AuthenticatedLayout() {
  const { isAuthenticated } = useAuth();

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  return (
    <div className="authenticated-layout">
      <UserSidebar />
      <div className="content">
        <Outlet />
      </div>
    </div>
  );
}

4.2 インデックスルート

インデックスルートは、親ルートの URL に完全一致した場合に描画されるルートである。ファイルシステムにおける index.html に相当する概念だ。

const router = createBrowserRouter([
  {
    path: 'teams',
    element: <TeamsLayout />,
    children: [
      // /teams にアクセスした時に描画される
      // 子ルートが選択されていない状態のデフォルト表示
      {
        index: true,
        element: <TeamsOverview />,
      },
      // /teams/:teamId にアクセスした時に描画される
      {
        path: ':teamId',
        element: <TeamDetail />,
      },
    ],
  },
]);
// TeamsLayout: index ルートか子ルートかで表示が変わる
function TeamsLayout() {
  return (
    <div className="teams-layout">
      <h1>チーム管理</h1>
      <TeamsList />
      <div className="team-detail-area">
        {/* /teams → TeamsOverview が描画される */}
        {/* /teams/123 → TeamDetail が描画される */}
        <Outlet />
      </div>
    </div>
  );
}

function TeamsOverview() {
  return (
    <div className="placeholder">
      <p>左のリストからチームを選択してください</p>
    </div>
  );
}

4.3 動的セグメント

動的セグメント(Dynamic Segments)は、URL の一部を変数として扱う機能である。: プレフィックスで定義し、useParams フックで値を取得する。

import { useParams } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: 'users/:userId',
    element: <UserProfile />,
    loader: async ({ params }) => {
      // params.userId にアクセスできる
      const response = await fetch(`/api/users/${params.userId}`);
      return response.json();
    },
  },
  {
    // 複数の動的セグメント
    path: 'teams/:teamId/members/:memberId',
    element: <TeamMember />,
  },
]);

function UserProfile() {
  // URL パラメータを取得
  const { userId } = useParams<{ userId: string }>();

  return <div>ユーザー ID: {userId}</div>;
}

function TeamMember() {
  // 複数パラメータの取得
  const { teamId, memberId } = useParams<{
    teamId: string;
    memberId: string;
  }>();

  return (
    <div>
      <p>チーム: {teamId}</p>
      <p>メンバー: {memberId}</p>
    </div>
  );
}

4.4 オプショナルセグメント

React Router v6 では、動的セグメントにオプショナル修飾子 ? を付けることで、そのセグメントを省略可能にできる。

const router = createBrowserRouter([
  {
    // :lang は省略可能
    // /posts と /posts → 両方マッチ
    // /ja/posts と /en/posts → 両方マッチ
    path: ':lang?/posts',
    element: <PostsList />,
    loader: async ({ params }) => {
      const lang = params.lang || 'ja'; // デフォルト値
      return fetchPosts(lang);
    },
  },
]);

function PostsList() {
  const { lang } = useParams();
  const language = lang || 'ja';

  return (
    <div>
      <h1>{language === 'ja' ? '記事一覧' : 'Posts'}</h1>
      {/* ... */}
    </div>
  );
}

4.5 スプラットルート(Catch-All)

スプラットルート(*)は、URL の残りのすべてのセグメントにマッチするルートである。ファイルブラウザやドキュメントビューアなど、深い階層構造を扱う場合に有用である。

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },

      // /docs 以下のすべてのパスにマッチ
      // /docs/getting-started
      // /docs/api/hooks/useEffect
      // /docs/a/b/c/d/e
      {
        path: 'docs/*',
        element: <DocsViewer />,
      },

      // /files 以下のファイルパスにマッチ
      {
        path: 'files/*',
        element: <FileExplorer />,
      },

      // 404 キャッチオール: 他のルートにマッチしなかったすべてのパス
      {
        path: '*',
        element: <NotFoundPage />,
      },
    ],
  },
]);

function DocsViewer() {
  const { '*': splatPath } = useParams();
  // /docs/api/hooks → splatPath = "api/hooks"

  const segments = splatPath?.split('/') || [];

  return (
    <div className="docs">
      {/* パンくずリストの生成 */}
      <Breadcrumb segments={segments} />
      {/* ドキュメントの表示 */}
      <DocumentContent path={splatPath || ''} />
    </div>
  );
}

function FileExplorer() {
  const { '*': filePath } = useParams();
  // /files/images/photo.jpg → filePath = "images/photo.jpg"

  return (
    <div className="file-explorer">
      <h2>ファイル: {filePath}</h2>
      <FilePreview path={filePath || ''} />
    </div>
  );
}

4.6 ルートのマッチング優先順位

React Router v6 では、ルートの定義順序ではなく、パスの具体性に基づいてマッチングが行われる。これにより、ルートの定義順序を気にする必要がなくなった。

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      // 以下のルートは定義順序に関係なく、
      // 最も具体的なルートが優先される

      { path: 'users/new', element: <NewUser /> },        // 静的パス(最優先)
      { path: 'users/:userId', element: <UserDetail /> },  // 動的セグメント
      { path: 'users/*', element: <UsersCatchAll /> },     // スプラット(最後に評価)

      // /users/new → NewUser(静的パスが動的セグメントより優先)
      // /users/123 → UserDetail(動的セグメントがスプラットより優先)
      // /users/123/settings → UsersCatchAll(スプラットのみマッチ)
    ],
  },
]);

優先順位のルールをまとめると以下のようになる:

優先度パスの種類
1(最高)静的パス/users/new
2動的セグメント/users/:id
3オプショナルセグメント/users/:id?
4(最低)スプラット/users/*

5. Data APIs(v6.4+)

5.1 createBrowserRouter とデータルーティング

React Router v6.4 で導入された Data APIs は、ルーティングとデータフェッチを統合する画期的な仕組みである。従来は useEffect 内でデータを取得していたが、Data APIs ではルート定義にデータの読み込みと書き込みのロジックを直接記述できる。

import {
  createBrowserRouter,
  RouterProvider,
  useLoaderData,
} from 'react-router-dom';

// ルート定義にデータロジックを組み込む
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      {
        path: 'users',
        element: <UsersPage />,
        // ページ描画前にデータを取得する loader
        loader: async () => {
          const response = await fetch('/api/users');
          if (!response.ok) {
            throw new Response('Failed to fetch users', { status: 500 });
          }
          return response.json();
        },
      },
      {
        path: 'users/:userId',
        element: <UserDetailPage />,
        // パラメータ付きの loader
        loader: async ({ params }) => {
          const response = await fetch(`/api/users/${params.userId}`);
          if (!response.ok) {
            throw new Response('User not found', { status: 404 });
          }
          return response.json();
        },
      },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

5.2 loader — データの読み込み

loader 関数は、ルートがレンダリングされる前に実行され、そのルートで必要なデータを事前に取得する。コンポーネントのレンダリングとデータ取得を分離できるため、ウォーターフォールの問題を回避できる。

// loader 関数の定義(通常は別ファイルに分離する)
// src/routes/users.loader.ts

import type { LoaderFunctionArgs } from 'react-router-dom';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

// 基本的な loader
export async function usersLoader(): Promise<User[]> {
  const response = await fetch('/api/users');
  if (!response.ok) {
    throw new Response('ユーザー一覧の取得に失敗しました', {
      status: response.status,
    });
  }
  return response.json();
}

// パラメータを使用する loader
export async function userDetailLoader({ params }: LoaderFunctionArgs): Promise<User> {
  const { userId } = params;
  const response = await fetch(`/api/users/${userId}`);
  if (!response.ok) {
    throw new Response('ユーザーが見つかりません', { status: 404 });
  }
  return response.json();
}

// リクエスト情報を使用する loader
export async function searchLoader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const query = url.searchParams.get('q') || '';
  const page = parseInt(url.searchParams.get('page') || '1', 10);

  const response = await fetch(
    `/api/search?q=${encodeURIComponent(query)}&page=${page}`
  );
  return response.json();
}
// コンポーネントでの loader データの使用
import { useLoaderData } from 'react-router-dom';

function UsersPage() {
  // loader が返したデータを取得(型安全にするために型アサーションを使用)
  const users = useLoaderData() as User[];

  return (
    <div>
      <h1>ユーザー一覧</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            <Link to={`/users/${user.id}`}>{user.name}</Link>
            <span>{user.email}</span>
          </li>
        ))}
      </ul>
    </div>
  );
}

5.3 action — データの書き込み

action 関数は、フォーム送信(POST、PUT、DELETE 等)時に実行され、データの作成・更新・削除を処理する。HTML の <form> のセマンティクスを活用しつつ、クライアントサイドで処理を行う。

import { Form, useActionData, redirect } from 'react-router-dom';
import type { ActionFunctionArgs } from 'react-router-dom';

// action 関数の定義
export async function createUserAction({ request }: ActionFunctionArgs) {
  const formData = await request.formData();

  const newUser = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    role: formData.get('role') as string,
  };

  // バリデーション
  const errors: Record<string, string> = {};
  if (!newUser.name) errors.name = '名前は必須です';
  if (!newUser.email) errors.email = 'メールアドレスは必須です';
  if (!newUser.email?.includes('@')) errors.email = '有効なメールアドレスを入力してください';

  if (Object.keys(errors).length > 0) {
    return { errors }; // エラーを返す(ページは遷移しない)
  }

  // API に送信
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(newUser),
  });

  if (!response.ok) {
    return { errors: { form: 'ユーザーの作成に失敗しました' } };
  }

  const created = await response.json();
  // 成功時はリダイレクト
  return redirect(`/users/${created.id}`);
}

// ルート定義
const router = createBrowserRouter([
  {
    path: 'users/new',
    element: <CreateUserPage />,
    action: createUserAction,
  },
]);
// Form コンポーネントでの action の使用
function CreateUserPage() {
  const actionData = useActionData() as { errors?: Record<string, string> } | undefined;

  return (
    <div>
      <h1>新規ユーザー作成</h1>

      {/* React Router の Form コンポーネント */}
      {/* method="post" でこのルートの action が呼ばれる */}
      <Form method="post">
        <div>
          <label htmlFor="name">名前</label>
          <input id="name" name="name" type="text" />
          {actionData?.errors?.name && (
            <p className="error">{actionData.errors.name}</p>
          )}
        </div>

        <div>
          <label htmlFor="email">メールアドレス</label>
          <input id="email" name="email" type="email" />
          {actionData?.errors?.email && (
            <p className="error">{actionData.errors.email}</p>
          )}
        </div>

        <div>
          <label htmlFor="role">役割</label>
          <select id="role" name="role">
            <option value="user">一般ユーザー</option>
            <option value="admin">管理者</option>
          </select>
        </div>

        {actionData?.errors?.form && (
          <p className="error">{actionData.errors.form}</p>
        )}

        <button type="submit">作成</button>
      </Form>
    </div>
  );
}

5.4 defer と Await — 遅延データロード

defer を使うと、一部のデータを遅延させて読み込むことができる。ページの骨格を先に描画し、重い処理を後から表示するパターンに最適である。

import { defer, Await, useLoaderData } from 'react-router-dom';
import { Suspense } from 'react';

// loader: 重要なデータは即座に、重いデータは遅延させる
export async function dashboardLoader() {
  // 重要なデータ: すぐに必要(await する)
  const userResponse = await fetch('/api/user/me');
  const user = await userResponse.json();

  // 重いデータ: 遅延させる(await しない → Promise のまま渡す)
  const analyticsPromise = fetch('/api/analytics').then((r) => r.json());
  const recentActivityPromise = fetch('/api/activity').then((r) => r.json());

  return defer({
    user,                                    // 解決済みのデータ
    analytics: analyticsPromise,             // 未解決の Promise
    recentActivity: recentActivityPromise,   // 未解決の Promise
  });
}

function DashboardPage() {
  const { user, analytics, recentActivity } = useLoaderData() as {
    user: User;
    analytics: Promise<Analytics>;
    recentActivity: Promise<Activity[]>;
  };

  return (
    <div className="dashboard">
      {/* user は即座に利用可能 */}
      <h1>ようこそ、{user.name} さん</h1>

      {/* analytics は Suspense + Await で遅延描画 */}
      <section className="analytics">
        <Suspense fallback={<AnalyticsSkeleton />}>
          <Await
            resolve={analytics}
            errorElement={<p>分析データの読み込みに失敗しました</p>}
          >
            {(resolvedAnalytics) => (
              <AnalyticsChart data={resolvedAnalytics} />
            )}
          </Await>
        </Suspense>
      </section>

      {/* recentActivity も同様に遅延描画 */}
      <section className="activity">
        <Suspense fallback={<ActivitySkeleton />}>
          <Await resolve={recentActivity}>
            {(resolvedActivity) => (
              <ActivityFeed items={resolvedActivity} />
            )}
          </Await>
        </Suspense>
      </section>
    </div>
  );
}

5.5 loader と action の分離パターン

大規模アプリケーションでは、loader と action をルート定義ファイルから分離し、モジュール化することが推奨される。

src/
├── routes/
│   ├── users/
│   │   ├── users.route.tsx      # ルート定義
│   │   ├── users.loader.ts      # loader 関数
│   │   ├── users.action.ts      # action 関数
│   │   └── UsersPage.tsx        # ページコンポーネント
│   ├── user-detail/
│   │   ├── user-detail.route.tsx
│   │   ├── user-detail.loader.ts
│   │   ├── user-detail.action.ts
│   │   └── UserDetailPage.tsx
│   └── index.ts                 # すべてのルート定義をエクスポート
// src/routes/users/users.loader.ts
export async function usersLoader() {
  const res = await fetch('/api/users');
  if (!res.ok) throw new Response('Failed', { status: res.status });
  return res.json();
}

// src/routes/users/users.action.ts
export async function usersAction({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent');

  switch (intent) {
    case 'create':
      return handleCreate(formData);
    case 'delete':
      return handleDelete(formData);
    default:
      throw new Response('Unknown intent', { status: 400 });
  }
}

// src/routes/users/users.route.tsx
import { usersLoader } from './users.loader';
import { usersAction } from './users.action';
import { UsersPage } from './UsersPage';

export const usersRoute = {
  path: 'users',
  element: <UsersPage />,
  loader: usersLoader,
  action: usersAction,
};

6. ナビゲーション

6.1 useNavigate — プログラマティックナビゲーション

useNavigate フックは、イベントハンドラやエフェクト内からプログラマティックにナビゲーションを行うために使用する。<Link><Navigate> が使えない場面で活躍する。

import { useNavigate } from 'react-router-dom';

function LoginForm() {
  const navigate = useNavigate();

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

    try {
      const result = await loginAPI({
        email: formData.get('email') as string,
        password: formData.get('password') as string,
      });

      if (result.success) {
        // ログイン成功後にダッシュボードへ遷移
        navigate('/dashboard');
      }
    } catch (error) {
      console.error('ログインエラー:', error);
    }
  };

  return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
// navigate の様々な使い方
function NavigationExamples() {
  const navigate = useNavigate();

  return (
    <div>
      {/* 絶対パスで遷移 */}
      <button onClick={() => navigate('/users')}>
        ユーザー一覧
      </button>

      {/* 相対パスで遷移(現在のルートからの相対位置) */}
      <button onClick={() => navigate('settings')}>
        設定
      </button>

      {/* 履歴を置換(戻るボタンで戻れなくする) */}
      <button onClick={() => navigate('/home', { replace: true })}>
        ホーム(置換)
      </button>

      {/* 状態を渡しながら遷移 */}
      <button onClick={() => navigate('/checkout', {
        state: { cartItems: [1, 2, 3], total: 5000 }
      })}>
        購入手続き
      </button>

      {/* 履歴操作: 1 つ戻る */}
      <button onClick={() => navigate(-1)}>
        戻る
      </button>

      {/* 履歴操作: 2 つ戻る */}
      <button onClick={() => navigate(-2)}>
        2 ページ戻る
      </button>

      {/* 履歴操作: 1 つ進む */}
      <button onClick={() => navigate(1)}>
        進む
      </button>
    </div>
  );
}

6.2 useLocation — 現在のロケーション情報

useLocation フックは、現在の URL に関する情報を返す。パス、クエリパラメータ、ハッシュ、そして navigate<Link> 経由で渡された state にアクセスできる。

import { useLocation } from 'react-router-dom';

function CurrentLocationInfo() {
  const location = useLocation();

  // location オブジェクトの内容(URL: /users/123?tab=posts#section-2)
  // {
  //   pathname: '/users/123',
  //   search: '?tab=posts',
  //   hash: '#section-2',
  //   state: { from: '/dashboard' },  // navigate 時に渡された state
  //   key: 'abc123'                   // ユニークキー
  // }

  return (
    <div>
      <p>パス: {location.pathname}</p>
      <p>クエリ: {location.search}</p>
      <p>ハッシュ: {location.hash}</p>
      <p>状態: {JSON.stringify(location.state)}</p>
    </div>
  );
}
// 実践例: リダイレクト元の URL を保持してログイン後に戻す
function LoginPage() {
  const location = useLocation();
  const navigate = useNavigate();

  // ProtectedRoute から渡された元のパスを取得
  const from = (location.state as { from?: string })?.from || '/dashboard';

  const handleLogin = async (credentials: Credentials) => {
    await login(credentials);
    // ログイン前のページに戻る
    navigate(from, { replace: true });
  };

  return (
    <div>
      <h1>ログイン</h1>
      {location.state?.from && (
        <p className="info">
          このページにアクセスするにはログインが必要です
        </p>
      )}
      <LoginForm onSubmit={handleLogin} />
    </div>
  );
}

6.3 useParams — ルートパラメータの取得

useParams フックは、URL の動的セグメント(:paramName)の値を取得する。

import { useParams } from 'react-router-dom';

// ルート定義: /products/:category/:productId
function ProductPage() {
  const { category, productId } = useParams<{
    category: string;
    productId: string;
  }>();

  return (
    <div>
      <Breadcrumb items={['商品', category || '', productId || '']} />
      <ProductDetail category={category!} id={productId!} />
    </div>
  );
}

// ルート定義: /docs/*
function DocsPage() {
  // スプラットパラメータは '*' キーでアクセス
  const params = useParams();
  const docPath = params['*']; // 'getting-started/installation'

  return <DocRenderer path={docPath || ''} />;
}

6.4 useSearchParams — クエリパラメータの操作

useSearchParams フックは、URL のクエリパラメータ(?key=value)を読み書きするためのフックである。React の useState に似た API を持つ。

import { useSearchParams } from 'react-router-dom';

function ProductSearch() {
  const [searchParams, setSearchParams] = useSearchParams();

  // クエリパラメータの読み取り
  const query = searchParams.get('q') || '';
  const category = searchParams.get('category') || 'all';
  const sort = searchParams.get('sort') || 'relevance';
  const page = parseInt(searchParams.get('page') || '1', 10);
  // 複数値の取得
  const tags = searchParams.getAll('tag'); // ?tag=react&tag=typescript → ['react', 'typescript']

  // 検索実行
  const handleSearch = (newQuery: string) => {
    setSearchParams((prev) => {
      prev.set('q', newQuery);
      prev.set('page', '1'); // 検索語変更時はページをリセット
      return prev;
    });
  };

  // カテゴリ変更
  const handleCategoryChange = (newCategory: string) => {
    setSearchParams((prev) => {
      if (newCategory === 'all') {
        prev.delete('category');
      } else {
        prev.set('category', newCategory);
      }
      prev.set('page', '1');
      return prev;
    });
  };

  // ページ変更
  const handlePageChange = (newPage: number) => {
    setSearchParams((prev) => {
      prev.set('page', String(newPage));
      return prev;
    });
  };

  // すべてのフィルタをリセット
  const handleReset = () => {
    setSearchParams({});
  };

  return (
    <div>
      <SearchInput value={query} onChange={handleSearch} />
      <CategoryFilter value={category} onChange={handleCategoryChange} />
      <SortSelect value={sort} onChange={(s) =>
        setSearchParams(prev => { prev.set('sort', s); return prev; })
      } />
      <ProductGrid query={query} category={category} sort={sort} page={page} />
      <Pagination currentPage={page} onChange={handlePageChange} />
      <button onClick={handleReset}>フィルタをリセット</button>
    </div>
  );
}

6.5 ナビゲーション時の状態受け渡し

React Router では、ナビゲーション時に state を通じてデータを受け渡すことができる。この state は URL に表示されず、ブラウザの Session History に保存される。

// 状態を渡す側
function OrderConfirmation() {
  const navigate = useNavigate();

  const handleConfirm = async (order: Order) => {
    const result = await submitOrder(order);
    navigate('/order/complete', {
      state: {
        orderId: result.id,
        orderTotal: result.total,
        estimatedDelivery: result.deliveryDate,
      },
      replace: true, // 戻るボタンで注文確認画面に戻れなくする
    });
  };

  return <OrderForm onConfirm={handleConfirm} />;
}

// 状態を受け取る側
function OrderComplete() {
  const location = useLocation();
  const navigate = useNavigate();
  const state = location.state as {
    orderId: string;
    orderTotal: number;
    estimatedDelivery: string;
  } | null;

  // state がない場合のフォールバック(直接 URL にアクセスされた場合)
  if (!state) {
    return <Navigate to="/" replace />;
  }

  return (
    <div className="order-complete">
      <h1>ご注文ありがとうございます</h1>
      <p>注文番号: {state.orderId}</p>
      <p>合計金額: ¥{state.orderTotal.toLocaleString()}</p>
      <p>お届け予定日: {state.estimatedDelivery}</p>
      <button onClick={() => navigate('/')}>ホームに戻る</button>
    </div>
  );
}

7. 高度なルーティング

7.1 認証ガード(Protected Routes)

認証ガードは、未認証ユーザーが保護されたページにアクセスするのを防ぐ最も一般的なパターンの一つである。

// 方法 1: ラッパーコンポーネントパターン
import { Navigate, useLocation, Outlet } from 'react-router-dom';

interface AuthContextType {
  user: User | null;
  isAuthenticated: boolean;
}

function ProtectedRoute() {
  const { isAuthenticated } = useAuth();
  const location = useLocation();

  if (!isAuthenticated) {
    // 未認証の場合、ログインページにリダイレクト
    // 現在の URL を state に保持し、ログイン後に戻れるようにする
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }

  // 認証済みの場合、子ルートを描画
  return <Outlet />;
}

// ルート定義
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      // 公開ルート
      { path: 'login', element: <LoginPage /> },
      { path: 'register', element: <RegisterPage /> },
      { path: 'about', element: <AboutPage /> },

      // 保護されたルート(ProtectedRoute でラップ)
      {
        element: <ProtectedRoute />,
        children: [
          { path: 'dashboard', element: <DashboardPage /> },
          { path: 'profile', element: <ProfilePage /> },
          { path: 'settings', element: <SettingsPage /> },
        ],
      },
    ],
  },
]);
// 方法 2: loader ベースの認証ガード(Data APIs 使用時に推奨)
import { redirect } from 'react-router-dom';

// 認証チェック用のヘルパー関数
async function requireAuth(request: Request) {
  const token = localStorage.getItem('auth_token');

  if (!token) {
    const url = new URL(request.url);
    throw redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
  }

  // トークンの有効性を検証
  try {
    const response = await fetch('/api/auth/verify', {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!response.ok) throw new Error('Invalid token');
    return response.json();
  } catch {
    localStorage.removeItem('auth_token');
    throw redirect('/login');
  }
}

// 各ルートの loader で認証チェック
const router = createBrowserRouter([
  {
    path: 'dashboard',
    element: <DashboardPage />,
    loader: async ({ request }) => {
      const user = await requireAuth(request);
      const dashboardData = await fetchDashboard();
      return { user, ...dashboardData };
    },
  },
]);

7.2 ロールベースアクセス制御

ユーザーのロール(役割)に応じてアクセスできるページを制御するパターンである。

type UserRole = 'user' | 'editor' | 'admin' | 'super_admin';

interface RoleGuardProps {
  allowedRoles: UserRole[];
  fallback?: React.ReactNode;
}

function RoleGuard({ allowedRoles, fallback }: RoleGuardProps) {
  const { user } = useAuth();

  if (!user) {
    return <Navigate to="/login" replace />;
  }

  if (!allowedRoles.includes(user.role)) {
    // 権限がない場合
    return fallback || <ForbiddenPage />;
  }

  return <Outlet />;
}

// ルート定義
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      // 全ユーザーアクセス可能
      { path: 'dashboard', element: <Dashboard /> },

      // エディター以上のみアクセス可能
      {
        element: <RoleGuard allowedRoles={['editor', 'admin', 'super_admin']} />,
        children: [
          { path: 'content', element: <ContentManagement /> },
          { path: 'content/new', element: <ContentEditor /> },
        ],
      },

      // 管理者のみアクセス可能
      {
        element: <RoleGuard allowedRoles={['admin', 'super_admin']} />,
        children: [
          { path: 'admin', element: <AdminPanel /> },
          { path: 'admin/users', element: <UserManagement /> },
        ],
      },

      // スーパー管理者のみ
      {
        element: <RoleGuard allowedRoles={['super_admin']} />,
        children: [
          { path: 'admin/system', element: <SystemSettings /> },
        ],
      },
    ],
  },
]);
// 権限不足ページ
function ForbiddenPage() {
  const navigate = useNavigate();

  return (
    <div className="forbidden">
      <h1>403 - アクセス権限がありません</h1>
      <p>このページにアクセスする権限がありません。</p>
      <button onClick={() => navigate(-1)}>前のページに戻る</button>
      <Link to="/dashboard">ダッシュボードに戻る</Link>
    </div>
  );
}

7.3 遅延読み込み(Lazy Loading)

大規模アプリケーションでは、すべてのページコンポーネントを初期ロード時にダウンロードすると、バンドルサイズが大きくなりすぎる。React.lazy と React Router の lazy プロパティを使って、ルートごとにコード分割を行える。

import { createBrowserRouter } from 'react-router-dom';
import { lazy, Suspense } from 'react';

// 方法 1: React.lazy を使用
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));
const Settings = lazy(() => import('./pages/Settings'));

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      {
        path: 'dashboard',
        element: (
          <Suspense fallback={<PageSkeleton />}>
            <Dashboard />
          </Suspense>
        ),
      },
      {
        path: 'analytics',
        element: (
          <Suspense fallback={<PageSkeleton />}>
            <Analytics />
          </Suspense>
        ),
      },
    ],
  },
]);
// 方法 2: React Router の lazy プロパティ(推奨)
// loader, action, Component をまとめて遅延読み込みできる
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      {
        path: 'dashboard',
        // lazy: ルートモジュール全体を遅延読み込み
        lazy: async () => {
          const { DashboardPage, dashboardLoader } = await import(
            './routes/dashboard'
          );
          return {
            Component: DashboardPage,
            loader: dashboardLoader,
          };
        },
      },
      {
        path: 'analytics',
        lazy: async () => {
          const module = await import('./routes/analytics');
          return {
            Component: module.AnalyticsPage,
            loader: module.analyticsLoader,
            action: module.analyticsAction,
          };
        },
      },
      {
        path: 'settings',
        lazy: async () => {
          // デフォルトエクスポートの場合
          const { default: SettingsPage, loader } = await import(
            './routes/settings'
          );
          return { Component: SettingsPage, loader };
        },
      },
    ],
  },
]);
// routes/dashboard.tsx — lazy で読み込まれるモジュール
export async function dashboardLoader() {
  const data = await fetch('/api/dashboard');
  return data.json();
}

export function DashboardPage() {
  const data = useLoaderData();
  return (
    <div>
      <h1>ダッシュボード</h1>
      {/* ... */}
    </div>
  );
}

7.4 条件付きルーティング

ユーザーの状態やアプリケーションの設定に応じて、動的にルートを切り替えるパターンである。

// フィーチャーフラグによるルーティング
function useRoutes() {
  const { features } = useFeatureFlags();

  return createBrowserRouter([
    {
      path: '/',
      element: <Layout />,
      children: [
        { index: true, element: <Home /> },

        // フィーチャーフラグに応じてルートを切り替え
        features.newDashboard
          ? { path: 'dashboard', lazy: () => import('./routes/dashboard-v2') }
          : { path: 'dashboard', lazy: () => import('./routes/dashboard-v1') },

        // フィーチャーフラグが有効な場合のみルートを追加
        ...(features.experimentalChat
          ? [{ path: 'chat', lazy: () => import('./routes/chat') }]
          : []),
      ],
    },
  ]);
}

8. エラーハンドリング

8.1 errorElement — ルートレベルのエラー境界

React Router v6.4 では、各ルートに errorElement を設定することで、そのルートとその子孫ルートで発生したエラーをキャッチし、フォールバック UI を描画できる。これは React のエラー境界(Error Boundary)のルーティング版である。

import {
  createBrowserRouter,
  useRouteError,
  isRouteErrorResponse,
  Link,
} from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    // ルートレベルのエラー境界: アプリ全体の最後の砦
    errorElement: <RootErrorBoundary />,
    children: [
      { index: true, element: <HomePage /> },
      {
        path: 'users',
        element: <UsersLayout />,
        // このルート独自のエラー境界
        errorElement: <UsersErrorBoundary />,
        loader: usersLoader,
        children: [
          { index: true, element: <UsersList /> },
          {
            path: ':userId',
            element: <UserDetail />,
            // さらに細かいエラー境界
            errorElement: <UserDetailError />,
            loader: userDetailLoader,
          },
        ],
      },
      {
        path: 'settings',
        element: <SettingsPage />,
        // errorElement を設定しない場合、
        // 親の errorElement にエラーがバブルアップする
      },
    ],
  },
]);

8.2 useRouteError — エラー情報の取得

useRouteError フックを使って、エラー境界内でエラーの詳細情報にアクセスできる。

import { useRouteError, isRouteErrorResponse, Link } from 'react-router-dom';

function RootErrorBoundary() {
  const error = useRouteError();

  // Response として throw されたエラー(loader/action 内での throw new Response(...))
  if (isRouteErrorResponse(error)) {
    return (
      <div className="error-page">
        <h1>{error.status} - {error.statusText}</h1>
        <p>{error.data}</p>

        {error.status === 404 && (
          <div>
            <p>お探しのページは見つかりませんでした。</p>
            <Link to="/">ホームに戻る</Link>
          </div>
        )}

        {error.status === 401 && (
          <div>
            <p>ログインが必要です。</p>
            <Link to="/login">ログインページへ</Link>
          </div>
        )}

        {error.status === 403 && (
          <div>
            <p>このページにアクセスする権限がありません。</p>
            <Link to="/">ホームに戻る</Link>
          </div>
        )}

        {error.status >= 500 && (
          <div>
            <p>サーバーエラーが発生しました。しばらく待ってから再試行してください。</p>
            <button onClick={() => window.location.reload()}>
              ページを再読み込み
            </button>
          </div>
        )}
      </div>
    );
  }

  // 通常の JavaScript エラー
  if (error instanceof Error) {
    return (
      <div className="error-page">
        <h1>予期しないエラーが発生しました</h1>
        <p>{error.message}</p>
        {process.env.NODE_ENV === 'development' && (
          <pre className="error-stack">{error.stack}</pre>
        )}
        <Link to="/">ホームに戻る</Link>
      </div>
    );
  }

  // 未知のエラー型
  return (
    <div className="error-page">
      <h1>エラーが発生しました</h1>
      <p>予期しない問題が発生しました。</p>
      <Link to="/">ホームに戻る</Link>
    </div>
  );
}

8.3 loader/action でのエラーの throw

loaderaction 内で Response オブジェクトを throw することで、意図的にエラーページを表示できる。

// loader 内でのエラー throw パターン

// パターン 1: Response を throw
export async function userLoader({ params }: LoaderFunctionArgs) {
  const response = await fetch(`/api/users/${params.userId}`);

  if (response.status === 404) {
    throw new Response('ユーザーが見つかりません', {
      status: 404,
      statusText: 'Not Found',
    });
  }

  if (response.status === 403) {
    throw new Response('このユーザーのプロフィールを表示する権限がありません', {
      status: 403,
      statusText: 'Forbidden',
    });
  }

  if (!response.ok) {
    throw new Response('データの取得中にエラーが発生しました', {
      status: response.status,
    });
  }

  return response.json();
}

// パターン 2: json ヘルパーを使用(構造化されたエラーデータ)
import { json } from 'react-router-dom';

export async function orderLoader({ params }: LoaderFunctionArgs) {
  const order = await fetchOrder(params.orderId!);

  if (!order) {
    throw json(
      {
        message: '注文が見つかりません',
        suggestion: '注文番号を確認してください',
      },
      { status: 404 }
    );
  }

  return order;
}

8.4 404 ページの実装

404 ページを実装するには、スプラットルート(*)と errorElement の 2 つのアプローチがある。

// アプローチ 1: スプラットルート(明示的な 404 ページ)
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      { index: true, element: <Home /> },
      { path: 'about', element: <About /> },
      // 他のどのルートにもマッチしない場合
      { path: '*', element: <NotFoundPage /> },
    ],
  },
]);

// アプローチ 2: errorElement(loader での 404 エラーも含む)
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <Home /> },
      {
        path: 'users/:userId',
        element: <UserDetail />,
        loader: async ({ params }) => {
          const user = await fetchUser(params.userId!);
          if (!user) {
            throw new Response('Not Found', { status: 404 });
          }
          return user;
        },
      },
    ],
  },
]);

// 404 ページコンポーネント
function NotFoundPage() {
  const location = useLocation();

  return (
    <div className="not-found">
      <h1>404</h1>
      <h2>ページが見つかりません</h2>
      <p>
        <code>{location.pathname}</code> は存在しません。
      </p>
      <div className="not-found-actions">
        <Link to="/">ホームに戻る</Link>
        <button onClick={() => window.history.back()}>
          前のページに戻る
        </button>
      </div>
    </div>
  );
}

8.5 エラー境界の階層設計

エラー境界を適切に階層化することで、エラーの影響範囲を最小限に抑えられる。

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    // 最上位のエラー境界: 致命的なエラーのみキャッチ
    errorElement: <CriticalErrorPage />,
    children: [
      {
        path: 'dashboard',
        element: <DashboardLayout />,
        // ダッシュボード全体のエラー境界
        errorElement: <DashboardError />,
        children: [
          {
            index: true,
            element: <DashboardHome />,
          },
          {
            path: 'widgets/:widgetId',
            element: <WidgetDetail />,
            // 個別ウィジェットのエラー境界
            // ウィジェットのエラーがダッシュボード全体に影響しない
            errorElement: <WidgetError />,
            loader: widgetLoader,
          },
        ],
      },
    ],
  },
]);

// ウィジェットレベルのエラー: ダッシュボードは引き続き利用可能
function WidgetError() {
  const error = useRouteError();

  return (
    <div className="widget-error">
      <p>このウィジェットの読み込みに失敗しました</p>
      <button onClick={() => window.location.reload()}>
        再試行
      </button>
    </div>
  );
}

9. フォームとアクション

9.1 Form コンポーネント

React Router の <Form> コンポーネントは、HTML の <form> のセマンティクスを維持しつつ、クライアントサイドでフォーム送信を処理する。<form> のように HTTP リクエストを送信するのではなく、対応するルートの action 関数を呼び出す。

import { Form, useActionData, useNavigation } from 'react-router-dom';

function ContactForm() {
  const actionData = useActionData() as ActionResponse | undefined;
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <Form method="post" className="contact-form">
      <fieldset disabled={isSubmitting}>
        <div className="form-group">
          <label htmlFor="name">お名前</label>
          <input
            id="name"
            name="name"
            type="text"
            required
            aria-invalid={actionData?.errors?.name ? true : undefined}
          />
          {actionData?.errors?.name && (
            <span className="error">{actionData.errors.name}</span>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="email">メールアドレス</label>
          <input id="email" name="email" type="email" required />
          {actionData?.errors?.email && (
            <span className="error">{actionData.errors.email}</span>
          )}
        </div>

        <div className="form-group">
          <label htmlFor="message">メッセージ</label>
          <textarea id="message" name="message" rows={5} required />
        </div>

        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? '送信中...' : '送信する'}
        </button>
      </fieldset>

      {actionData?.success && (
        <div className="success-message">
          メッセージが送信されました。ありがとうございます。
        </div>
      )}
    </Form>
  );
}

// 対応する action
export async function contactAction({ request }: ActionFunctionArgs) {
  const formData = await request.formData();

  const data = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    message: formData.get('message') as string,
  };

  // バリデーション
  const errors: Record<string, string> = {};
  if (data.name.length < 2) errors.name = '名前は2文字以上で入力してください';
  if (!data.email.match(/^[^@]+@[^@]+$/)) errors.email = '有効なメールアドレスを入力してください';

  if (Object.keys(errors).length > 0) {
    return { errors, success: false };
  }

  await sendContactMessage(data);
  return { success: true };
}
// Form の method による動作の違い

// GET: クエリパラメータとしてデータが URL に付加される
// → loader が再実行される(action は呼ばれない)
<Form method="get" action="/search">
  <input name="q" type="search" />
  <button type="submit">検索</button>
</Form>
// 送信後の URL: /search?q=react

// POST: action 関数が実行される
<Form method="post">
  <input name="title" />
  <button type="submit">作成</button>
</Form>

// DELETE: action 関数が method="delete" のリクエストとして実行される
<Form method="delete" action={`/users/${userId}`}>
  <button type="submit">削除</button>
</Form>

// 別のルートの action を呼び出す
<Form method="post" action="/api/newsletter">
  <input name="email" type="email" />
  <button type="submit">購読する</button>
</Form>

9.2 useFetcher — ナビゲーションなしのデータ操作

useFetcher は、ページ遷移を伴わずにデータの読み書きを行うための強力なフックである。一覧ページ内での個別アイテムの更新、インラインフォーム、バックグラウンドデータ取得などに最適である。

import { useFetcher } from 'react-router-dom';

// 例 1: いいねボタン(ナビゲーションなしの action 呼び出し)
function LikeButton({ postId, isLiked }: { postId: string; isLiked: boolean }) {
  const fetcher = useFetcher();

  // Optimistic UI: サーバー応答を待たずに UI を更新
  const optimisticIsLiked = fetcher.formData
    ? fetcher.formData.get('liked') === 'true'
    : isLiked;

  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <input type="hidden" name="liked" value={String(!optimisticIsLiked)} />
      <button
        type="submit"
        className={optimisticIsLiked ? 'liked' : 'not-liked'}
        disabled={fetcher.state !== 'idle'}
      >
        {optimisticIsLiked ? '♥' : '♡'}
      </button>
    </fetcher.Form>
  );
}

// 例 2: インライン削除(一覧から個別アイテムを削除)
function TodoItem({ todo }: { todo: Todo }) {
  const fetcher = useFetcher();

  // 削除中のアイテムをフェードアウト
  const isDeleting =
    fetcher.state !== 'idle' &&
    fetcher.formData?.get('intent') === 'delete';

  return (
    <li style={{ opacity: isDeleting ? 0.3 : 1 }}>
      <span>{todo.title}</span>

      {/* 完了/未完了の切り替え */}
      <fetcher.Form method="post" action={`/todos/${todo.id}`}>
        <input type="hidden" name="intent" value="toggle" />
        <input type="hidden" name="completed" value={String(!todo.completed)} />
        <button type="submit">
          {todo.completed ? '✓' : '○'}
        </button>
      </fetcher.Form>

      {/* 削除 */}
      <fetcher.Form method="post" action={`/todos/${todo.id}`}>
        <input type="hidden" name="intent" value="delete" />
        <button type="submit">削除</button>
      </fetcher.Form>
    </li>
  );
}
// 例 3: fetcher.load でデータを読み込む(検索サジェスト)
function SearchAutocomplete() {
  const fetcher = useFetcher<string[]>();

  const handleInput = (event: React.ChangeEvent<HTMLInputElement>) => {
    const query = event.target.value;
    if (query.length >= 2) {
      // loader を呼び出してサジェストを取得
      fetcher.load(`/api/suggestions?q=${encodeURIComponent(query)}`);
    }
  };

  return (
    <div className="autocomplete">
      <input
        type="search"
        onChange={handleInput}
        placeholder="検索..."
      />
      {fetcher.state === 'loading' && <Spinner />}
      {fetcher.data && (
        <ul className="suggestions">
          {fetcher.data.map((suggestion) => (
            <li key={suggestion}>
              <Link to={`/search?q=${encodeURIComponent(suggestion)}`}>
                {suggestion}
              </Link>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

9.3 useSubmit — プログラマティックなフォーム送信

useSubmit フックを使うと、プログラマティックにフォームデータを送信できる。自動保存やデバウンス付き検索など、ユーザーの入力に応じた自動送信に活用できる。

import { useSubmit } from 'react-router-dom';

// 例 1: デバウンス付きリアルタイム検索
function SearchForm() {
  const submit = useSubmit();
  const [searchParams] = useSearchParams();

  const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const form = event.currentTarget.form;
    if (form) {
      // 300ms 遅延でフォーム送信(GET リクエスト)
      const timeoutId = setTimeout(() => {
        submit(form, { method: 'get' });
      }, 300);

      return () => clearTimeout(timeoutId);
    }
  };

  return (
    <Form method="get" action="/search">
      <input
        name="q"
        type="search"
        defaultValue={searchParams.get('q') || ''}
        onChange={handleChange}
        placeholder="検索..."
      />
    </Form>
  );
}

// 例 2: 自動保存フォーム
function AutoSaveForm({ document }: { document: Document }) {
  const submit = useSubmit();
  const timerRef = useRef<NodeJS.Timeout>();

  const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
    // 既存のタイマーをクリア
    if (timerRef.current) clearTimeout(timerRef.current);

    // 2 秒後に自動保存
    timerRef.current = setTimeout(() => {
      const formData = new FormData();
      formData.set('content', event.target.value);
      formData.set('intent', 'auto-save');

      submit(formData, {
        method: 'post',
        action: `/documents/${document.id}`,
      });
    }, 2000);
  };

  return (
    <textarea
      name="content"
      defaultValue={document.content}
      onChange={handleChange}
    />
  );
}

9.4 useNavigation — 送信状態の監視

useNavigation フックは、現在のナビゲーション/送信の状態を監視するために使用する。フォーム送信中のローディング表示や UI の無効化に活用する。

import { useNavigation } from 'react-router-dom';

function SubmitButton({ label = '送信' }: { label?: string }) {
  const navigation = useNavigation();

  // navigation.state の値:
  // - 'idle': 何も起きていない
  // - 'submitting': action が実行中
  // - 'loading': action 完了後、次の loader が実行中

  const isSubmitting = navigation.state === 'submitting';
  const isLoading = navigation.state === 'loading';

  return (
    <button type="submit" disabled={isSubmitting || isLoading}>
      {isSubmitting
        ? '送信中...'
        : isLoading
        ? '読み込み中...'
        : label}
    </button>
  );
}

// グローバルローディングインジケーター
function GlobalLoadingIndicator() {
  const navigation = useNavigation();

  if (navigation.state === 'idle') return null;

  return (
    <div className="global-loading-bar">
      <div className="progress-bar" />
    </div>
  );
}

9.5 Optimistic UI パターン

Optimistic UI は、サーバーの応答を待たずにUIを先に更新するパターンである。useFetcher と組み合わせることで実現できる。

function TodoList({ todos }: { todos: Todo[] }) {
  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  );
}

function TodoItem({ todo }: { todo: Todo }) {
  const fetcher = useFetcher();

  // Optimistic UI: fetcher がデータを送信中の場合、
  // 送信したデータを使って UI を先に更新する
  let isCompleted = todo.completed;
  if (fetcher.formData) {
    isCompleted = fetcher.formData.get('completed') === 'true';
  }

  return (
    <li className={isCompleted ? 'completed' : ''}>
      <fetcher.Form method="post" action={`/todos/${todo.id}`}>
        <input type="hidden" name="intent" value="toggle" />
        <input type="hidden" name="completed" value={String(!isCompleted)} />
        <button type="submit" className="toggle-btn">
          {isCompleted ? '✅' : '⬜'}
        </button>
      </fetcher.Form>
      <span className="todo-title">{todo.title}</span>
    </li>
  );
}

10. データの読み込みパターン

10.1 並列データ読み込み

React Router の Data APIs を活用することで、従来の useEffect パターンで発生しがちなウォーターフォール(順次読み込み)問題を解消できる。

// ❌ アンチパターン: ウォーターフォール
// 各コンポーネントが順番にデータを取得するため、遅延が積み重なる
function Dashboard() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);
  const [notifications, setNotifications] = useState(null);

  useEffect(() => {
    // 1. まず user を取得(500ms)
    fetchUser().then((u) => {
      setUser(u);
      // 2. user 取得後に posts を取得(400ms)
      fetchPosts(u.id).then(setPosts);
      // 3. user 取得後に notifications を取得(300ms)
      fetchNotifications(u.id).then(setNotifications);
    });
    // 合計: 500ms + max(400ms, 300ms) = 900ms
  }, []);

  // ...
}
// ✅ 推奨パターン: loader で並列読み込み
export async function dashboardLoader() {
  // Promise.all で並列実行
  const [user, posts, notifications] = await Promise.all([
    fetchUser(),          // 500ms
    fetchPosts(),         // 400ms
    fetchNotifications(), // 300ms
  ]);
  // 合計: max(500ms, 400ms, 300ms) = 500ms(40%以上の短縮)

  return { user, posts, notifications };
}

// さらに、ネストされたルートの loader も並列実行される
const router = createBrowserRouter([
  {
    path: 'dashboard',
    element: <DashboardLayout />,
    // この loader と...
    loader: async () => {
      return fetchUserProfile();  // 500ms
    },
    children: [
      {
        index: true,
        element: <DashboardHome />,
        // この loader は並列に実行される!
        loader: async () => {
          return fetchDashboardStats();  // 400ms
        },
      },
    ],
  },
]);
// 両方の loader が同時に開始 → 合計: max(500ms, 400ms) = 500ms

10.2 defer による段階的データ読み込み

すべてのデータを待ってから表示するのではなく、重要なデータだけ先に表示し、残りを後から読み込むパターンである。

import { defer, Await, useLoaderData } from 'react-router-dom';
import { Suspense } from 'react';

export async function productPageLoader({ params }: LoaderFunctionArgs) {
  // 重要: 商品の基本情報はすぐに必要
  const product = await fetchProduct(params.productId!);

  // 重要度低: レビューや関連商品は後から読み込んでもよい
  const reviewsPromise = fetchReviews(params.productId!);
  const relatedProductsPromise = fetchRelatedProducts(params.productId!);
  const priceHistoryPromise = fetchPriceHistory(params.productId!);

  return defer({
    product,                          // 解決済み(即座に利用可能)
    reviews: reviewsPromise,          // 未解決(遅延読み込み)
    relatedProducts: relatedProductsPromise,
    priceHistory: priceHistoryPromise,
  });
}

function ProductPage() {
  const data = useLoaderData() as {
    product: Product;
    reviews: Promise<Review[]>;
    relatedProducts: Promise<Product[]>;
    priceHistory: Promise<PricePoint[]>;
  };

  return (
    <div className="product-page">
      {/* 商品情報: 即座に表示 */}
      <ProductHeader product={data.product} />
      <ProductGallery images={data.product.images} />
      <ProductDescription description={data.product.description} />
      <AddToCartButton product={data.product} />

      {/* レビュー: 遅延読み込み */}
      <section className="reviews-section">
        <h2>カスタマーレビュー</h2>
        <Suspense fallback={<ReviewsSkeleton count={3} />}>
          <Await
            resolve={data.reviews}
            errorElement={<p>レビューの読み込みに失敗しました</p>}
          >
            {(reviews: Review[]) => <ReviewsList reviews={reviews} />}
          </Await>
        </Suspense>
      </section>

      {/* 関連商品: 遅延読み込み */}
      <section className="related-section">
        <h2>関連商品</h2>
        <Suspense fallback={<ProductGridSkeleton count={4} />}>
          <Await resolve={data.relatedProducts}>
            {(products: Product[]) => <ProductGrid products={products} />}
          </Await>
        </Suspense>
      </section>

      {/* 価格推移: 遅延読み込み */}
      <section className="price-history-section">
        <h2>価格推移</h2>
        <Suspense fallback={<ChartSkeleton />}>
          <Await resolve={data.priceHistory}>
            {(history: PricePoint[]) => <PriceChart data={history} />}
          </Await>
        </Suspense>
      </section>
    </div>
  );
}

10.3 データの再検証(Revalidation)

React Router の Data APIs では、action が実行された後、影響を受けるすべてのルートの loader が自動的に再実行される。これにより、データの一貫性が保証される。

// ルート定義
const router = createBrowserRouter([
  {
    path: 'todos',
    element: <TodosLayout />,
    loader: todosLoader,        // Todo 一覧を取得
    children: [
      {
        index: true,
        element: <TodosList />,
      },
      {
        path: ':todoId',
        element: <TodoDetail />,
        loader: todoDetailLoader,  // 個別 Todo を取得
        action: todoAction,        // Todo を更新/削除
      },
    ],
  },
]);

// todoAction が実行されると...
export async function todoAction({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent');

  if (intent === 'update') {
    await updateTodo(params.todoId!, {
      title: formData.get('title') as string,
      completed: formData.get('completed') === 'true',
    });
  }

  if (intent === 'delete') {
    await deleteTodo(params.todoId!);
    return redirect('/todos');
  }

  // action 完了後、React Router は自動的に以下を実行:
  // 1. todosLoader(一覧の再取得)
  // 2. todoDetailLoader(詳細の再取得、まだそのルートにいる場合)
  return null;
}
// shouldRevalidate: 再検証の制御
const router = createBrowserRouter([
  {
    path: 'dashboard',
    element: <Dashboard />,
    loader: dashboardLoader,
    // 再検証するかどうかを細かく制御
    shouldRevalidate: ({ currentUrl, nextUrl, formAction, formMethod }) => {
      // URL が変わっていなければ再検証しない
      if (currentUrl.pathname === nextUrl.pathname) {
        return false;
      }

      // GET リクエスト(検索など)では再検証しない
      if (formMethod === 'GET') {
        return false;
      }

      // デフォルト: 再検証する
      return true;
    },
  },
]);

10.4 リソースルートによる API エンドポイント

リソースルートは、UI を持たないルートで、データの読み書きのみを行う。API エンドポイント的に利用できる。

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },

      // リソースルート: UI を持たず、loader/action のみ
      {
        path: 'api/newsletter',
        action: async ({ request }) => {
          const formData = await request.formData();
          const email = formData.get('email') as string;
          await subscribeToNewsletter(email);
          return { success: true };
        },
      },

      // CSV ダウンロード用リソースルート
      {
        path: 'api/export/csv',
        loader: async () => {
          const data = await fetchExportData();
          const csv = convertToCSV(data);
          return new Response(csv, {
            headers: {
              'Content-Type': 'text/csv',
              'Content-Disposition': 'attachment; filename="export.csv"',
            },
          });
        },
      },
    ],
  },
]);

// リソースルートの利用
function NewsletterSignup() {
  const fetcher = useFetcher();

  return (
    <fetcher.Form method="post" action="/api/newsletter">
      <input name="email" type="email" placeholder="メールアドレス" />
      <button type="submit">
        {fetcher.state === 'submitting' ? '登録中...' : '登録'}
      </button>
      {fetcher.data?.success && <p>登録が完了しました!</p>}
    </fetcher.Form>
  );
}

11. React Router v7 / フレームワークモード

11.1 React Router v7 の概要

React Router v7 は、Remix フレームワークとの統合を完了した重大なリリースである。ライブラリとしての React Router と、フレームワークとしての Remix が一つのプロジェクトに統合され、開発者は同一の API セットの中から必要な機能レベルを選択できるようになった。

React Router v7 のモード:

┌─────────────────────────────────────────────┐
│ フレームワークモード(旧 Remix)              │
│ ┌─────────────────────────────────────────┐ │
│ │ SSR / SSG / Streaming                   │ │
│ │ ファイルベースルーティング               │ │
│ │ サーバー機能(server loader/action)     │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ ライブラリモード(従来の React Router)│ │ │
│ │ │ - createBrowserRouter               │ │ │
│ │ │ - loader / action (クライアント)     │ │ │
│ │ │ - Data APIs                         │ │ │
│ │ │ - ナビゲーション hooks              │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘

11.2 フレームワークモードのセットアップ

# 新規プロジェクトの作成
npx create-react-router@latest my-app
cd my-app
npm install
npm run dev
// react-router.config.ts — フレームワーク設定ファイル
import type { Config } from '@react-router/dev/config';

export default {
  // アプリケーションディレクトリ
  appDirectory: 'app',

  // ビルド出力ディレクトリ
  buildDirectory: 'build',

  // SSR を有効にするか
  ssr: true,

  // プリレンダリングするパスの指定(SSG)
  async prerender() {
    return ['/', '/about', '/pricing'];
  },
} satisfies Config;

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

フレームワークモードでは、ファイルシステムの構造がそのままルートの構造となる。

app/
├── root.tsx                    # ルートレイアウト(<html>, <head>, <body>)
├── routes/
│   ├── _index.tsx             # / → インデックスルート
│   ├── about.tsx              # /about
│   ├── contact.tsx            # /contact
│   │
│   ├── dashboard.tsx          # /dashboard(レイアウト)
│   ├── dashboard._index.tsx   # /dashboard(インデックス)
│   ├── dashboard.settings.tsx # /dashboard/settings
│   ├── dashboard.analytics.tsx# /dashboard/analytics
│   │
│   ├── users.tsx              # /users(レイアウト)
│   ├── users._index.tsx       # /users
│   ├── users.$userId.tsx      # /users/:userId(動的セグメント)
│   │
│   ├── _auth.tsx              # パスレスレイアウト(URL に影響なし)
│   ├── _auth.login.tsx        # /login
│   ├── _auth.register.tsx     # /register
│   │
│   ├── blog_.tsx              # /blog(レイアウトをエスケープ)
│   ├── blog_.$slug.tsx        # /blog/:slug(blog レイアウトなし)
│   │
│   └── $.tsx                  # /* キャッチオールルート
│
├── components/                 # 共有コンポーネント
├── lib/                        # ユーティリティ
└── styles/                     # CSS

ファイル名の規約を表にまとめる:

ファイル名パターンURL パス説明
_index.tsx/インデックスルート
about.tsx/about静的ルート
users.$userId.tsx/users/:userId動的セグメント
dashboard.tsxレイアウトのみレイアウトルート
dashboard._index.tsx/dashboardネストされたインデックス
dashboard.settings.tsx/dashboard/settingsネストされたルート
_auth.tsxパスなしパスレスレイアウト
_auth.login.tsx/loginパスレスレイアウト配下
blog_.$slug.tsx/blog/:slugレイアウトエスケープ
$.tsx/*キャッチオール

11.4 ルートモジュールの構造

フレームワークモードのルートファイルは、エクスポートする関数によって動作が定義される。

// app/routes/users.$userId.tsx

import type { Route } from './+types/users.$userId';

// メタデータの定義(<head> 内の <title> や <meta> タグ)
export function meta({ data }: Route.MetaArgs) {
  return [
    { title: `${data.user.name} のプロフィール` },
    { name: 'description', content: `${data.user.name} のプロフィールページ` },
  ];
}

// ヘッダー設定
export function headers() {
  return {
    'Cache-Control': 'public, max-age=300',
  };
}

// リンクプリロード
export function links() {
  return [
    { rel: 'stylesheet', href: '/styles/user-profile.css' },
  ];
}

// サーバーサイド loader(サーバーでのみ実行)
export async function loader({ params }: Route.LoaderArgs) {
  const user = await db.users.findUnique({
    where: { id: params.userId },
  });

  if (!user) {
    throw new Response('User not found', { status: 404 });
  }

  return { user };
}

// クライアントサイド loader(CSR ナビゲーション時に実行)
export async function clientLoader({ params, serverLoader }: Route.ClientLoaderArgs) {
  // キャッシュをチェック
  const cached = sessionStorage.getItem(`user:${params.userId}`);
  if (cached) {
    return { user: JSON.parse(cached) };
  }

  // キャッシュミス: サーバー loader を呼び出す
  const data = await serverLoader<typeof loader>();
  sessionStorage.setItem(`user:${params.userId}`, JSON.stringify(data.user));
  return data;
}

// サーバーサイド action(フォーム送信処理)
export async function action({ request, params }: Route.ActionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent');

  if (intent === 'update-profile') {
    await db.users.update({
      where: { id: params.userId },
      data: {
        name: formData.get('name') as string,
        bio: formData.get('bio') as string,
      },
    });
  }

  return { success: true };
}

// コンポーネント(デフォルトエクスポート)
export default function UserProfile({ loaderData }: Route.ComponentProps) {
  const { user } = loaderData;

  return (
    <div className="user-profile">
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </div>
  );
}

// エラー境界
export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error) && error.status === 404) {
    return <div>ユーザーが見つかりませんでした</div>;
  }

  return <div>エラーが発生しました</div>;
}

11.5 Remix からの移行

Remix v2 から React Router v7 フレームワークモードへの移行は、比較的スムーズに行える。主な変更点は以下のとおりである。

// Remix v2
import { json, useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';

export async function loader({ params }: LoaderFunctionArgs) {
  const user = await getUser(params.id);
  return json({ user });
}

export default function UserPage() {
  const { user } = useLoaderData<typeof loader>();
  return <div>{user.name}</div>;
}
// React Router v7 フレームワークモード
import type { Route } from './+types/users.$id';

export async function loader({ params }: Route.LoaderArgs) {
  const user = await getUser(params.id);
  return { user };  // json() ラッパー不要
}

export default function UserPage({ loaderData }: Route.ComponentProps) {
  const { user } = loaderData;  // props として受け取る
  return <div>{user.name}</div>;
}

主な変更点の一覧:

Remix v2React Router v7備考
@remix-run/reactreact-routerパッケージ名
@remix-run/node@react-router/nodeサーバーアダプタ
json() ヘルパー直接 returnjson() は不要に
useLoaderData<typeof loader>()loaderData propsProps として受け取る
remix.config.jsreact-router.config.ts設定ファイル
app/entry.server.tsxapp/entry.server.tsx互換(変更少)

11.6 型安全性の強化

React Router v7 のフレームワークモードでは、ルートモジュールの型が自動生成され、loader のデータ型が props に自動的に反映される。

// app/routes/products.$productId.tsx
import type { Route } from './+types/products.$productId';
// ↑ この型は自動生成される。以下の型情報を含む:
// - Route.LoaderArgs: { params: { productId: string }, request: Request }
// - Route.ActionArgs: { params: { productId: string }, request: Request }
// - Route.ComponentProps: { loaderData: ReturnType<typeof loader> }

export async function loader({ params }: Route.LoaderArgs) {
  // params.productId は string 型として型安全にアクセスできる
  // params.nonExistent はコンパイルエラーになる
  const product = await fetchProduct(params.productId);
  return { product, reviews: await fetchReviews(params.productId) };
}

export default function ProductPage({ loaderData }: Route.ComponentProps) {
  // loaderData.product は loader の戻り値から型推論される
  // loaderData.nonExistent はコンパイルエラーになる
  const { product, reviews } = loaderData;

  return (
    <div>
      <h1>{product.name}</h1>  {/* 型安全 */}
      <p>¥{product.price}</p>  {/* 型安全 */}
    </div>
  );
}

12. ミドルウェアとガード

12.1 ミドルウェアパターンの実装

React Router にはフレームワークレベルのミドルウェア機能が組み込まれていないが、loader をチェーンさせることでミドルウェア的なパターンを実現できる。

// ミドルウェアチェーンの実装
type MiddlewareFn = (
  args: LoaderFunctionArgs
) => Promise<Response | null | void>;

function composeMiddleware(...middlewares: MiddlewareFn[]) {
  return async (args: LoaderFunctionArgs) => {
    for (const middleware of middlewares) {
      const result = await middleware(args);
      if (result instanceof Response) {
        throw result; // Response を throw するとナビゲーションが中断される
      }
    }
  };
}

// 個別ミドルウェアの定義
const requireAuth: MiddlewareFn = async ({ request }) => {
  const session = await getSession(request);
  if (!session.userId) {
    const url = new URL(request.url);
    throw redirect(`/login?redirectTo=${url.pathname}`);
  }
};

const requireAdmin: MiddlewareFn = async ({ request }) => {
  const session = await getSession(request);
  const user = await getUserById(session.userId);
  if (user?.role !== 'admin') {
    throw new Response('Forbidden', { status: 403 });
  }
};

const logAccess: MiddlewareFn = async ({ request }) => {
  console.log(`[${new Date().toISOString()}] ${request.method} ${request.url}`);
};

// ルート定義でミドルウェアを適用
const router = createBrowserRouter([
  {
    path: 'admin',
    element: <AdminLayout />,
    loader: async (args) => {
      // ミドルウェアチェーンを実行
      await composeMiddleware(logAccess, requireAuth, requireAdmin)(args);
      // ミドルウェアを通過したらデータを取得
      return fetchAdminData();
    },
    children: [
      { index: true, element: <AdminDashboard /> },
      { path: 'users', element: <AdminUsers /> },
    ],
  },
]);

12.2 認証フローの設計

完全な認証フローを React Router で実装する例を示す。

// auth.ts — 認証ユーティリティ
interface AuthSession {
  userId: string;
  token: string;
  expiresAt: number;
}

class AuthManager {
  private static SESSION_KEY = 'auth_session';

  static getSession(): AuthSession | null {
    const data = localStorage.getItem(this.SESSION_KEY);
    if (!data) return null;

    const session: AuthSession = JSON.parse(data);
    if (Date.now() > session.expiresAt) {
      this.clearSession();
      return null;
    }
    return session;
  }

  static setSession(session: AuthSession): void {
    localStorage.setItem(this.SESSION_KEY, JSON.stringify(session));
  }

  static clearSession(): void {
    localStorage.removeItem(this.SESSION_KEY);
  }

  static isAuthenticated(): boolean {
    return this.getSession() !== null;
  }
}
// ルート定義: 完全な認証フロー
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <RootError />,
    children: [
      // 公開ルート
      { index: true, element: <LandingPage /> },
      { path: 'about', element: <AboutPage /> },

      // 認証ルート(ログイン済みの場合はダッシュボードにリダイレクト)
      {
        path: 'login',
        element: <LoginPage />,
        loader: () => {
          if (AuthManager.isAuthenticated()) {
            throw redirect('/dashboard');
          }
          return null;
        },
        action: async ({ request }) => {
          const formData = await request.formData();
          const email = formData.get('email') as string;
          const password = formData.get('password') as string;

          try {
            const result = await loginAPI(email, password);
            AuthManager.setSession({
              userId: result.userId,
              token: result.token,
              expiresAt: result.expiresAt,
            });

            // ログイン前のページに戻る
            const url = new URL(request.url);
            const redirectTo = url.searchParams.get('redirectTo') || '/dashboard';
            return redirect(redirectTo);
          } catch (error) {
            return {
              error: 'メールアドレスまたはパスワードが正しくありません',
            };
          }
        },
      },

      // ログアウト(リソースルート)
      {
        path: 'logout',
        action: () => {
          AuthManager.clearSession();
          return redirect('/');
        },
      },

      // 保護されたルート群
      {
        element: <ProtectedRoute />,
        children: [
          {
            path: 'dashboard',
            element: <DashboardPage />,
            loader: async () => {
              const session = AuthManager.getSession();
              return fetchDashboard(session!.token);
            },
          },
          // ...他の保護されたルート
        ],
      },
    ],
  },
]);

12.3 リダイレクト処理のパターン

import { redirect } from 'react-router-dom';

// パターン 1: loader でのリダイレクト
export async function protectedLoader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request);

  if (!session) {
    // 未認証 → ログインページへ
    const url = new URL(request.url);
    throw redirect(`/login?redirectTo=${encodeURIComponent(url.pathname)}`);
  }

  return { user: session.user };
}

// パターン 2: action でのリダイレクト
export async function createPostAction({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const post = await createPost({
    title: formData.get('title') as string,
    body: formData.get('body') as string,
  });

  // 作成成功後、新しい投稿ページへリダイレクト
  return redirect(`/posts/${post.id}`);
}

// パターン 3: 条件付きリダイレクト
export async function onboardingLoader() {
  const user = await getCurrentUser();

  if (!user) {
    return redirect('/login');
  }

  if (user.onboardingComplete) {
    return redirect('/dashboard');
  }

  if (!user.emailVerified) {
    return redirect('/verify-email');
  }

  return { user };
}

// パターン 4: 外部 URL へのリダイレクト
export async function oauthCallback({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const code = url.searchParams.get('code');

  if (!code) {
    // 外部 OAuth プロバイダへリダイレクト
    const oauthUrl = `https://oauth.provider.com/authorize?client_id=${CLIENT_ID}`;
    throw redirect(oauthUrl);
  }

  // OAuth コードを処理...
  const session = await exchangeCode(code);
  return redirect('/dashboard');
}

12.4 React Router v7 のミドルウェア

React Router v7 のフレームワークモードでは、ルートモジュールに unstable_middleware を定義することで、よりフォーマルなミドルウェアパターンが使える。

// app/routes/admin.tsx — フレームワークモードのミドルウェア例
import type { Route } from './+types/admin';

// ミドルウェアの定義(v7 の unstable API)
export const unstable_middleware: Route.unstable_MiddlewareFunction[] = [
  async ({ request, context }, next) => {
    // 認証チェック
    const session = await getSession(request.headers.get('Cookie'));
    if (!session.has('userId')) {
      throw redirect('/login');
    }

    // コンテキストにユーザー情報を追加
    context.user = await getUserById(session.get('userId'));

    // 次のミドルウェアまたは loader を実行
    return next();
  },
  async ({ context }, next) => {
    // ロールチェック
    if (context.user.role !== 'admin') {
      throw new Response('Forbidden', { status: 403 });
    }
    return next();
  },
];

export async function loader({ context }: Route.LoaderArgs) {
  // ミドルウェアでセットされた context が利用可能
  return { adminData: await getAdminData(context.user.id) };
}

13. テスト戦略

13.1 MemoryRouter を使ったコンポーネントテスト

テスト環境ではブラウザの History API が利用できないため、createMemoryRouter または MemoryRouter を使用してテストを行う。

// テスト対象のコンポーネント
// src/components/UserProfile.tsx
import { useParams, Link } from 'react-router-dom';

export function UserProfile() {
  const { userId } = useParams<{ userId: string }>();

  return (
    <div>
      <h1>ユーザープロフィール</h1>
      <p data-testid="user-id">ID: {userId}</p>
      <Link to="/users">一覧に戻る</Link>
    </div>
  );
}
// テストファイル
// src/components/UserProfile.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { UserProfile } from './UserProfile';

describe('UserProfile', () => {
  function renderWithRouter(initialEntry: string = '/users/123') {
    const router = createMemoryRouter(
      [
        {
          path: '/users/:userId',
          element: <UserProfile />,
        },
        {
          path: '/users',
          element: <div>ユーザー一覧</div>,
        },
      ],
      {
        initialEntries: [initialEntry],
      }
    );

    return render(<RouterProvider router={router} />);
  }

  test('ユーザー ID が表示される', () => {
    renderWithRouter('/users/456');
    expect(screen.getByTestId('user-id')).toHaveTextContent('ID: 456');
  });

  test('一覧リンクをクリックすると一覧ページに遷移する', async () => {
    renderWithRouter();
    const user = userEvent.setup();

    await user.click(screen.getByText('一覧に戻る'));
    expect(screen.getByText('ユーザー一覧')).toBeInTheDocument();
  });
});

13.2 loader と action のテスト

loader と action は純粋な非同期関数なので、直接テストできる。

// src/routes/users.loader.ts
export async function usersLoader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const search = url.searchParams.get('search') || '';

  const response = await fetch(`/api/users?search=${search}`);
  if (!response.ok) {
    throw new Response('Failed to fetch users', { status: response.status });
  }

  return response.json();
}
// src/routes/users.loader.test.ts
import { usersLoader } from './users.loader';

// fetch のモック
global.fetch = vi.fn();

describe('usersLoader', () => {
  afterEach(() => {
    vi.restoreAllMocks();
  });

  test('ユーザー一覧を取得できる', async () => {
    const mockUsers = [
      { id: '1', name: 'Alice' },
      { id: '2', name: 'Bob' },
    ];

    (fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUsers,
    });

    const request = new Request('http://localhost/users');
    const result = await usersLoader({
      request,
      params: {},
      context: {},
    } as LoaderFunctionArgs);

    expect(result).toEqual(mockUsers);
    expect(fetch).toHaveBeenCalledWith('/api/users?search=');
  });

  test('検索パラメータが正しく渡される', async () => {
    (fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
      ok: true,
      json: async () => [],
    });

    const request = new Request('http://localhost/users?search=Alice');
    await usersLoader({
      request,
      params: {},
      context: {},
    } as LoaderFunctionArgs);

    expect(fetch).toHaveBeenCalledWith('/api/users?search=Alice');
  });

  test('API エラー時に Response を throw する', async () => {
    (fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
      ok: false,
      status: 500,
    });

    const request = new Request('http://localhost/users');

    await expect(
      usersLoader({ request, params: {}, context: {} } as LoaderFunctionArgs)
    ).rejects.toBeInstanceOf(Response);
  });
});
// action のテスト
// src/routes/users.action.test.ts
import { createUserAction } from './users.action';

describe('createUserAction', () => {
  test('有効なデータでユーザーを作成できる', async () => {
    const formData = new FormData();
    formData.set('name', 'Alice');
    formData.set('email', 'alice@example.com');

    (fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
      ok: true,
      json: async () => ({ id: '1', name: 'Alice', email: 'alice@example.com' }),
    });

    const request = new Request('http://localhost/users/new', {
      method: 'POST',
      body: formData,
    });

    const result = await createUserAction({
      request,
      params: {},
      context: {},
    } as ActionFunctionArgs);

    // redirect が返される
    expect(result).toBeInstanceOf(Response);
    expect((result as Response).status).toBe(302);
  });

  test('バリデーションエラー時にエラーを返す', async () => {
    const formData = new FormData();
    formData.set('name', '');  // 空の名前
    formData.set('email', 'invalid');  // 無効なメール

    const request = new Request('http://localhost/users/new', {
      method: 'POST',
      body: formData,
    });

    const result = await createUserAction({
      request,
      params: {},
      context: {},
    } as ActionFunctionArgs);

    expect(result).toEqual({
      errors: {
        name: expect.any(String),
        email: expect.any(String),
      },
    });
  });
});

13.3 統合テスト

完全なルーティングフローをテストする統合テストの例を示す。

// src/routes/__tests__/integration.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';

// テスト用のルート定義(本番と同じ構造)
function createTestRouter(initialEntries: string[] = ['/']) {
  return createMemoryRouter(
    [
      {
        path: '/',
        element: <RootLayout />,
        errorElement: <ErrorPage />,
        children: [
          { index: true, element: <HomePage /> },
          {
            path: 'users',
            element: <UsersPage />,
            loader: () => [
              { id: '1', name: 'Alice' },
              { id: '2', name: 'Bob' },
            ],
          },
          {
            path: 'users/:userId',
            element: <UserDetailPage />,
            loader: ({ params }) => ({
              id: params.userId,
              name: `User ${params.userId}`,
            }),
          },
        ],
      },
    ],
    { initialEntries }
  );
}

describe('アプリケーション統合テスト', () => {
  test('ホームページからユーザー一覧、詳細へ遷移できる', async () => {
    const user = userEvent.setup();
    const router = createTestRouter();
    render(<RouterProvider router={router} />);

    // ホームページ
    expect(screen.getByText('ホーム')).toBeInTheDocument();

    // ユーザー一覧へ遷移
    await user.click(screen.getByRole('link', { name: 'ユーザー' }));
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
      expect(screen.getByText('Bob')).toBeInTheDocument();
    });

    // ユーザー詳細へ遷移
    await user.click(screen.getByText('Alice'));
    await waitFor(() => {
      expect(screen.getByText('User 1')).toBeInTheDocument();
    });
  });

  test('存在しないルートで 404 エラーが表示される', () => {
    const router = createTestRouter(['/nonexistent']);
    render(<RouterProvider router={router} />);

    expect(screen.getByText(/ページが見つかりません/)).toBeInTheDocument();
  });
});

13.4 テストユーティリティの作成

テストを効率化するためのユーティリティを作成する。

// src/test/router-utils.tsx
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { render, type RenderOptions } from '@testing-library/react';
import type { RouteObject } from 'react-router-dom';

interface RenderWithRouterOptions extends Omit<RenderOptions, 'wrapper'> {
  routes?: RouteObject[];
  initialEntries?: string[];
}

export function renderWithRouter(
  component: React.ReactElement,
  {
    routes = [],
    initialEntries = ['/'],
    ...renderOptions
  }: RenderWithRouterOptions = {}
) {
  const defaultRoutes: RouteObject[] = [
    {
      path: '/',
      element: component,
    },
    ...routes,
  ];

  const router = createMemoryRouter(defaultRoutes, { initialEntries });

  return {
    ...render(<RouterProvider router={router} />, renderOptions),
    router,
  };
}

// 使用例
test('ナビゲーションが動作する', async () => {
  const { router } = renderWithRouter(<MyComponent />, {
    routes: [
      { path: '/other', element: <div>Other Page</div> },
    ],
  });

  // テスト内でルーターの状態を確認
  expect(router.state.location.pathname).toBe('/');
});

14. パフォーマンス最適化

14.1 コード分割戦略

ルートベースのコード分割は、アプリケーションのパフォーマンスを大幅に改善する最も効果的な手法の一つである。

// 基本的なルートベースのコード分割
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      // ホームページは初期バンドルに含める(最初に必ずアクセスする)
      { index: true, element: <HomePage /> },

      // 他のページは lazy で遅延読み込み
      {
        path: 'dashboard',
        lazy: () => import('./routes/dashboard'),
      },
      {
        path: 'analytics',
        lazy: () => import('./routes/analytics'),
      },
      {
        path: 'settings',
        lazy: () => import('./routes/settings'),
      },

      // 重いライブラリを含むページ(チャートライブラリ等)
      {
        path: 'reports',
        lazy: async () => {
          // チャートライブラリも同時に読み込む
          const [module] = await Promise.all([
            import('./routes/reports'),
            import('chart.js'), // 依存ライブラリのプリロード
          ]);
          return module;
        },
      },
    ],
  },
]);
// routes/dashboard.tsx — lazy モジュールのエクスポートパターン
import { useLoaderData } from 'react-router-dom';

export async function loader() {
  return fetchDashboardData();
}

export function Component() {
  const data = useLoaderData();
  return <DashboardUI data={data} />;
}

// ErrorBoundary もエクスポート可能
export function ErrorBoundary() {
  return <div>ダッシュボードの読み込みエラー</div>;
}

14.2 プリフェッチ戦略

ユーザーがリンクにホバーした時や、ビューポートに入った時に事前にデータやコードを読み込むことで、体感速度を向上させる。

// カスタムプリフェッチ Link コンポーネント
import { Link, type LinkProps } from 'react-router-dom';
import { useCallback, useRef } from 'react';

interface PrefetchLinkProps extends LinkProps {
  prefetch?: 'intent' | 'render' | 'none';
  prefetchTimeout?: number;
}

function PrefetchLink({
  prefetch = 'intent',
  prefetchTimeout = 100,
  onMouseEnter,
  ...props
}: PrefetchLinkProps) {
  const timeoutRef = useRef<NodeJS.Timeout>();

  const handleMouseEnter = useCallback(
    (e: React.MouseEvent<HTMLAnchorElement>) => {
      if (prefetch === 'intent') {
        timeoutRef.current = setTimeout(() => {
          // ルートモジュールのプリフェッチ
          const path = typeof props.to === 'string' ? props.to : props.to.pathname;
          if (path) {
            // Webpack/Vite のプリフェッチヒント
            const link = document.createElement('link');
            link.rel = 'prefetch';
            link.href = path;
            document.head.appendChild(link);
          }
        }, prefetchTimeout);
      }
      onMouseEnter?.(e);
    },
    [prefetch, prefetchTimeout, props.to, onMouseEnter]
  );

  const handleMouseLeave = useCallback(() => {
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
  }, []);

  return (
    <Link
      {...props}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
    />
  );
}

14.3 View Transitions API の統合

React Router v6.14+ では、View Transitions API との統合がサポートされている。ページ遷移時にアニメーションを適用できる。

// View Transitions の有効化
import { Link, useNavigate, useViewTransitionState } from 'react-router-dom';

function App() {
  return (
    <RouterProvider
      router={router}
      // ルート全体で View Transitions を有効化
      future={{ v7_startTransition: true }}
    />
  );
}

// Link コンポーネントで View Transitions を使用
function ProductCard({ product }: { product: Product }) {
  return (
    <Link
      to={`/products/${product.id}`}
      // viewTransition プロパティを追加
      viewTransition
    >
      <img
        src={product.image}
        alt={product.name}
        // View Transition Name でアニメーション要素を特定
        style={{ viewTransitionName: `product-image-${product.id}` }}
      />
      <h3 style={{ viewTransitionName: `product-title-${product.id}` }}>
        {product.name}
      </h3>
    </Link>
  );
}

// 遷移先のページ
function ProductDetail() {
  const { productId } = useParams();
  const product = useLoaderData() as Product;

  return (
    <div>
      <img
        src={product.image}
        alt={product.name}
        // 同じ viewTransitionName を設定するとアニメーションが適用される
        style={{ viewTransitionName: `product-image-${productId}` }}
      />
      <h1 style={{ viewTransitionName: `product-title-${productId}` }}>
        {product.name}
      </h1>
    </div>
  );
}
/* View Transitions のアニメーション定義 */

/* デフォルトのフェードアニメーションをカスタマイズ */
::view-transition-old(root) {
  animation: slide-out 0.3s ease-in-out;
}

::view-transition-new(root) {
  animation: slide-in 0.3s ease-in-out;
}

@keyframes slide-out {
  from { opacity: 1; transform: translateX(0); }
  to { opacity: 0; transform: translateX(-30px); }
}

@keyframes slide-in {
  from { opacity: 0; transform: translateX(30px); }
  to { opacity: 1; transform: translateX(0); }
}

/* 商品画像の共有要素アニメーション */
::view-transition-group(product-image-*) {
  animation-duration: 0.4s;
  animation-timing-function: ease-in-out;
}

14.4 スクロール復元

React Router はスクロール位置の復元を組み込みでサポートしている。

import { ScrollRestoration } from 'react-router-dom';

function RootLayout() {
  return (
    <div>
      <Navigation />
      <main>
        <Outlet />
      </main>
      <Footer />

      {/* スクロール復元コンポーネント */}
      {/* ページ遷移時にスクロール位置を管理する */}
      <ScrollRestoration
        // スクロール位置のキーをカスタマイズ
        getKey={(location, matches) => {
          // パスベースのキー(デフォルト)
          // return location.pathname;

          // 特定のパスではクエリパラメータも含める
          const searchPages = ['/search', '/products'];
          if (searchPages.includes(location.pathname)) {
            return location.pathname + location.search;
          }

          return location.pathname;
        }}
      />
    </div>
  );
}

14.5 ルートレベルのキャッシング

loader のデータをキャッシュして、不要な再取得を避ける戦略を示す。

// シンプルなキャッシュユーティリティ
class RouteCache {
  private cache = new Map<string, { data: unknown; timestamp: number }>();
  private ttl: number;

  constructor(ttlMs: number = 5 * 60 * 1000) { // デフォルト 5 分
    this.ttl = ttlMs;
  }

  get<T>(key: string): T | null {
    const entry = this.cache.get(key);
    if (!entry) return null;
    if (Date.now() - entry.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }
    return entry.data as T;
  }

  set(key: string, data: unknown): void {
    this.cache.set(key, { data, timestamp: Date.now() });
  }

  invalidate(key: string): void {
    this.cache.delete(key);
  }

  invalidateAll(): void {
    this.cache.clear();
  }
}

const routeCache = new RouteCache();

// キャッシュ付き loader
export async function usersLoader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const cacheKey = `users:${url.search}`;

  // キャッシュチェック
  const cached = routeCache.get<User[]>(cacheKey);
  if (cached) return cached;

  // キャッシュミス: API から取得
  const response = await fetch(`/api/users${url.search}`);
  const data = await response.json();

  // キャッシュに保存
  routeCache.set(cacheKey, data);

  return data;
}

// action 実行後にキャッシュを無効化
export async function createUserAction({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  await createUser(formData);

  // ユーザー一覧のキャッシュを無効化
  routeCache.invalidate('users:');

  return redirect('/users');
}

15. 他のルーティングライブラリとの比較

15.1 比較概要

React エコシステムにはReact Router 以外にも複数のルーティングソリューションが存在する。プロジェクトの要件に応じて最適な選択肢を選ぶことが重要である。

特性React Router v7TanStack RouterNext.js App RouterWouter
バンドルサイズ~14KB (gzip)~12KB (gzip)フレームワーク内蔵~1.5KB (gzip)
型安全性v7で強化ファーストクラス部分的基本的
Data APIsloader/actionloaderServer Componentsなし
SSR サポートv7で完全対応対応ファーストクラス限定的
ファイルベースルーティングv7フレームワークモードプラグイン標準なし
学習コスト中〜高中〜高
エコシステム最大成長中最大級小規模
フレームワーク統合独立/フレームワーク独立Next.js 専用独立
RSC サポートv7 で対応計画中ファーストクラスなし

15.2 TanStack Router

TanStack Router(旧 React Location)は、型安全性に特化したモダンなルーティングライブラリである。TypeScript ファーストの設計が最大の特徴だ。

// TanStack Router: 型安全なルーティング
import {
  createRootRoute,
  createRoute,
  createRouter,
  RouterProvider,
} from '@tanstack/react-router';

// ルートの定義(型が自動推論される)
const rootRoute = createRootRoute({
  component: RootLayout,
});

const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: HomePage,
});

const userRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/users/$userId',
  // バリデーション付き検索パラメータ(型安全)
  validateSearch: (search: Record<string, unknown>) => ({
    tab: (search.tab as string) || 'profile',
    page: Number(search.page) || 1,
  }),
  // loader の型が自動推論される
  loader: async ({ params }) => {
    // params.userId は string 型として型安全
    return fetchUser(params.userId);
  },
  component: UserPage,
});

// ルートツリーの構築
const routeTree = rootRoute.addChildren([indexRoute, userRoute]);

// ルーターの作成
const router = createRouter({ routeTree });

// 型宣言(グローバルな型安全性を確保)
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

function App() {
  return <RouterProvider router={router} />;
}

React Router vs TanStack Router の使い分け

React Router を選ぶべき場合:
  ├── 既存の React Router プロジェクトがある
  ├── Remix の後継としてフレームワーク機能が必要
  ├── コミュニティサポートとドキュメントの充実を重視
  └── 段階的な移行(ライブラリ → フレームワーク)を計画

TanStack Router を選ぶべき場合:
  ├── 型安全性を最優先する(特に大規模 TypeScript プロジェクト)
  ├── 検索パラメータの型安全な管理が必要
  ├── 新規プロジェクトで最新のアプローチを採用したい
  └── ファーストクラスの devtools が欲しい

15.3 Next.js App Router

Next.js の App Router は、React Server Components(RSC)をベースとしたフルスタックのルーティングソリューションである。

// Next.js App Router: ファイルベースルーティング + Server Components

// app/users/[userId]/page.tsx
// ファイルの配置がそのままルートになる

// Server Component(デフォルト)
export default async function UserPage({
  params,
}: {
  params: Promise<{ userId: string }>;
}) {
  const { userId } = await params;
  // サーバーで直接データベースにアクセスできる
  const user = await db.users.findUnique({ where: { id: userId } });

  return (
    <div>
      <h1>{user?.name}</h1>
      {/* Client Component は 'use client' を宣言する */}
      <LikeButton userId={userId} />
    </div>
  );
}

React Router vs Next.js の使い分け

React Router を選ぶべき場合:
  ├── SPA(シングルページアプリケーション)を構築する
  ├── 既存の Vite/Webpack プロジェクトにルーティングを追加する
  ├── フレームワークに依存しない柔軟性が必要
  ├── バックエンドが別のサーバー(Rails, Django 等)で構築されている
  └── クライアントサイドのみのアプリケーション

Next.js を選ぶべき場合:
  ├── SEO が重要なサイト(ブログ、EC、マーケティングサイト)
  ├── React Server Components をフル活用したい
  ├── Vercel デプロイと統合したい
  ├── フルスタック React アプリケーション
  └── SSR/SSG/ISR など多様なレンダリング戦略が必要

15.4 Wouter

Wouter は、最小限のルーティングライブラリである。バンドルサイズが極めて小さく、シンプルなプロジェクトに最適だ。

// Wouter: 最小限の API
import { Route, Switch, Link, useRoute, useLocation } from 'wouter';

function App() {
  return (
    <div>
      <nav>
        <Link href="/">Home</Link>
        <Link href="/about">About</Link>
      </nav>

      <Switch>
        <Route path="/" component={Home} />
        <Route path="/users/:id">
          {(params) => <UserProfile id={params.id} />}
        </Route>
        <Route path="/about" component={About} />
        <Route>404 Not Found</Route>
      </Switch>
    </div>
  );
}

// useRoute フック
function UserProfile({ id }: { id: string }) {
  const [match, params] = useRoute('/users/:id');
  return <div>User: {id}</div>;
}

Wouter を選ぶべき場合

  • バンドルサイズを極限まで小さくしたい
  • ルーティング機能が最小限で十分
  • Data APIs や高度な機能が不要
  • 学習コストを最小限にしたい

15.5 選択のフローチャート

プロジェクトの規模は?
├── 小規模(数ページ)
│   ├── バンドルサイズ重視 → Wouter
│   └── 将来の拡張を考慮 → React Router(ライブラリモード)
│
├── 中規模(数十ページ)
│   ├── SPA のみ → React Router(ライブラリモード)
│   ├── SSR が必要 → React Router v7(フレームワークモード)or Next.js
│   └── 型安全性最優先 → TanStack Router
│
└── 大規模(数百ページ)
    ├── クライアントサイド SPA → React Router + コード分割
    ├── フルスタック + SEO → Next.js or React Router v7
    └── 型安全 + クライアント SPA → TanStack Router

16. 実践的なプロジェクト構成

16.1 小規模プロジェクト(〜10 ページ)

小規模プロジェクトでは、シンプルなフラット構造が適している。

src/
├── main.tsx               # エントリーポイント + ルート定義
├── App.tsx                # RootLayout
├── pages/
│   ├── HomePage.tsx
│   ├── AboutPage.tsx
│   ├── ContactPage.tsx
│   ├── LoginPage.tsx
│   └── NotFoundPage.tsx
├── components/
│   ├── Header.tsx
│   ├── Footer.tsx
│   └── Navigation.tsx
├── hooks/
│   └── useAuth.ts
└── styles/
    └── global.css
// src/main.tsx — すべてのルートを一箇所で定義
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from './App';
import { HomePage } from './pages/HomePage';
import { AboutPage } from './pages/AboutPage';
import { ContactPage } from './pages/ContactPage';
import { LoginPage } from './pages/LoginPage';
import { NotFoundPage } from './pages/NotFoundPage';

const router = createBrowserRouter([
  {
    path: '/',
    element: <App />,
    errorElement: <NotFoundPage />,
    children: [
      { index: true, element: <HomePage /> },
      { path: 'about', element: <AboutPage /> },
      { path: 'contact', element: <ContactPage /> },
      { path: 'login', element: <LoginPage /> },
    ],
  },
]);

ReactDOM.createRoot(document.getElementById('root')!).render(
  <RouterProvider router={router} />
);

16.2 中規模プロジェクト(10〜50 ページ)

中規模プロジェクトでは、機能ベースのディレクトリ構造が効果的である。

src/
├── main.tsx
├── router.tsx              # ルート定義の集約
├── layouts/
│   ├── RootLayout.tsx
│   ├── DashboardLayout.tsx
│   └── AuthLayout.tsx
├── features/
│   ├── auth/
│   │   ├── pages/
│   │   │   ├── LoginPage.tsx
│   │   │   └── RegisterPage.tsx
│   │   ├── components/
│   │   │   ├── LoginForm.tsx
│   │   │   └── RegisterForm.tsx
│   │   ├── loaders.ts
│   │   ├── actions.ts
│   │   └── routes.tsx       # auth 関連のルート定義
│   ├── dashboard/
│   │   ├── pages/
│   │   │   ├── DashboardHome.tsx
│   │   │   └── AnalyticsPage.tsx
│   │   ├── components/
│   │   │   ├── StatsCard.tsx
│   │   │   └── Chart.tsx
│   │   ├── loaders.ts
│   │   └── routes.tsx
│   ├── users/
│   │   ├── pages/
│   │   │   ├── UsersListPage.tsx
│   │   │   └── UserDetailPage.tsx
│   │   ├── components/
│   │   │   ├── UserCard.tsx
│   │   │   └── UserForm.tsx
│   │   ├── loaders.ts
│   │   ├── actions.ts
│   │   └── routes.tsx
│   └── settings/
│       ├── pages/
│       ├── components/
│       └── routes.tsx
├── components/              # 共有コンポーネント
│   ├── ui/
│   │   ├── Button.tsx
│   │   └── Input.tsx
│   └── shared/
│       ├── ErrorPage.tsx
│       └── LoadingSpinner.tsx
├── hooks/
│   ├── useAuth.ts
│   └── useTheme.ts
├── lib/
│   ├── api.ts
│   └── auth.ts
└── types/
    └── index.ts
// src/features/users/routes.tsx
import type { RouteObject } from 'react-router-dom';

export const userRoutes: RouteObject[] = [
  {
    path: 'users',
    lazy: () => import('./pages/UsersListPage'),
  },
  {
    path: 'users/:userId',
    lazy: () => import('./pages/UserDetailPage'),
  },
  {
    path: 'users/:userId/edit',
    lazy: () => import('./pages/UserEditPage'),
  },
];

// src/router.tsx — 全ルートを集約
import { createBrowserRouter } from 'react-router-dom';
import { RootLayout } from './layouts/RootLayout';
import { DashboardLayout } from './layouts/DashboardLayout';
import { AuthLayout } from './layouts/AuthLayout';
import { authRoutes } from './features/auth/routes';
import { dashboardRoutes } from './features/dashboard/routes';
import { userRoutes } from './features/users/routes';
import { settingsRoutes } from './features/settings/routes';
import { ErrorPage } from './components/shared/ErrorPage';

export const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      // 認証ルート(パスレスレイアウト)
      {
        element: <AuthLayout />,
        children: authRoutes,
      },

      // 保護されたルート(ダッシュボードレイアウト)
      {
        element: <DashboardLayout />,
        children: [
          ...dashboardRoutes,
          ...userRoutes,
          ...settingsRoutes,
        ],
      },
    ],
  },
]);

16.3 大規模プロジェクト(50+ ページ)

大規模プロジェクトでは、ドメイン駆動のモジュール構造と遅延読み込みが重要となる。

src/
├── main.tsx
├── router/
│   ├── index.tsx           # ルーター作成
│   ├── routes.tsx          # トップレベルルート定義
│   ├── guards/
│   │   ├── AuthGuard.tsx
│   │   ├── RoleGuard.tsx
│   │   └── FeatureGuard.tsx
│   └── middleware/
│       ├── auth.ts
│       └── logging.ts
├── modules/
│   ├── core/               # コアモジュール(レイアウト、認証、共通)
│   │   ├── layouts/
│   │   ├── pages/
│   │   ├── hooks/
│   │   └── routes.tsx
│   ├── crm/                # CRM モジュール
│   │   ├── contacts/
│   │   │   ├── pages/
│   │   │   ├── components/
│   │   │   ├── hooks/
│   │   │   ├── api/
│   │   │   └── routes.tsx
│   │   ├── deals/
│   │   │   └── ...
│   │   └── routes.tsx      # CRM モジュール全体のルート
│   ├── analytics/           # 分析モジュール
│   │   └── ...
│   └── admin/               # 管理モジュール
│       └── ...
├── shared/                  # 共有リソース
│   ├── components/
│   ├── hooks/
│   ├── utils/
│   └── types/
└── config/
    ├── api.ts
    └── feature-flags.ts
// src/router/routes.tsx — 大規模プロジェクトのルート定義
import type { RouteObject } from 'react-router-dom';

export function createAppRoutes(): RouteObject[] {
  return [
    {
      path: '/',
      lazy: () => import('../modules/core/layouts/AppLayout'),
      errorElement: <GlobalErrorPage />,
      children: [
        // コアルート
        { index: true, lazy: () => import('../modules/core/pages/Home') },

        // CRM モジュール(遅延読み込み)
        {
          path: 'crm/*',
          lazy: async () => {
            const { crmRoutes } = await import('../modules/crm/routes');
            return {
              Component: () => <Outlet />,
              children: crmRoutes,
            };
          },
        },

        // 分析モジュール(遅延読み込み)
        {
          path: 'analytics/*',
          lazy: () => import('../modules/analytics/routes'),
        },

        // 管理モジュール(遅延読み込み + 権限チェック)
        {
          path: 'admin/*',
          lazy: () => import('../modules/admin/routes'),
        },
      ],
    },
  ];
}

16.4 ルート設定のベストプラクティス

// ルート定義のためのヘルパー関数
import type { RouteObject } from 'react-router-dom';

// 型安全なルート定義ヘルパー
function defineRoute(config: RouteObject & { id?: string }): RouteObject {
  return config;
}

// パス定数の集中管理
export const PATHS = {
  HOME: '/',
  LOGIN: '/login',
  REGISTER: '/register',
  DASHBOARD: '/dashboard',
  USERS: '/users',
  USER_DETAIL: (id: string) => `/users/${id}`,
  USER_EDIT: (id: string) => `/users/${id}/edit`,
  SETTINGS: '/settings',
  SETTINGS_PROFILE: '/settings/profile',
  SETTINGS_SECURITY: '/settings/security',
} as const;

// パスビルダーの使用例
function UsersList() {
  const users = useLoaderData() as User[];

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <Link to={PATHS.USER_DETAIL(user.id)}>{user.name}</Link>
          <Link to={PATHS.USER_EDIT(user.id)}>編集</Link>
        </li>
      ))}
    </ul>
  );
}

17. ベストプラクティスとアンチパターン

17.1 推奨されるベストプラクティス

1. Data APIs を積極的に活用する

// ✅ 推奨: loader でデータを取得
const router = createBrowserRouter([
  {
    path: 'users/:userId',
    element: <UserPage />,
    loader: async ({ params }) => {
      return fetchUser(params.userId!);
    },
  },
]);

function UserPage() {
  const user = useLoaderData() as User;
  return <div>{user.name}</div>; // データは常に利用可能
}

// ❌ 非推奨: useEffect でデータを取得
function UserPage() {
  const { userId } = useParams();
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser(userId!).then((data) => {
      setUser(data);
      setLoading(false);
    });
  }, [userId]);

  if (loading) return <Spinner />;
  if (!user) return <div>Not found</div>;
  return <div>{user.name}</div>;
}

2. 相対パスを活用する

// ✅ 推奨: 相対パスを使用(リファクタリングに強い)
// /dashboard/settings 内のコンポーネント
function SettingsNav() {
  return (
    <nav>
      <NavLink to=".">一般</NavLink>
      <NavLink to="profile">プロフィール</NavLink>
      <NavLink to="security">セキュリティ</NavLink>
      <NavLink to="..">ダッシュボードに戻る</NavLink>
    </nav>
  );
}

// ❌ 非推奨: 絶対パスのハードコーディング(変更に弱い)
function SettingsNav() {
  return (
    <nav>
      <NavLink to="/dashboard/settings">一般</NavLink>
      <NavLink to="/dashboard/settings/profile">プロフィール</NavLink>
      <NavLink to="/dashboard/settings/security">セキュリティ</NavLink>
      <NavLink to="/dashboard">ダッシュボードに戻る</NavLink>
    </nav>
  );
}

3. エラー境界を階層的に設定する

// ✅ 推奨: 適切な粒度のエラー境界
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    errorElement: <GlobalError />,  // アプリ全体のフォールバック
    children: [
      {
        path: 'dashboard',
        element: <Dashboard />,
        errorElement: <DashboardError />,  // セクションレベル
        children: [
          {
            path: 'widget/:id',
            element: <Widget />,
            errorElement: <WidgetError />,  // 個別コンポーネントレベル
          },
        ],
      },
    ],
  },
]);

// ❌ 非推奨: ルートレベルの errorElement のみ
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    errorElement: <GlobalError />,
    children: [
      // すべてのエラーが GlobalError に集約される
      // → 部分的なエラーでもアプリ全体がエラー画面になる
    ],
  },
]);

4. Form コンポーネントでミューテーションを行う

// ✅ 推奨: React Router の Form を使用
import { Form, useFetcher } from 'react-router-dom';

function DeleteButton({ itemId }: { itemId: string }) {
  const fetcher = useFetcher();

  return (
    <fetcher.Form method="delete" action={`/items/${itemId}`}>
      <button type="submit" disabled={fetcher.state !== 'idle'}>
        {fetcher.state !== 'idle' ? '削除中...' : '削除'}
      </button>
    </fetcher.Form>
  );
}

// ❌ 非推奨: 手動で fetch + useState 管理
function DeleteButton({ itemId }: { itemId: string }) {
  const [isDeleting, setIsDeleting] = useState(false);
  const navigate = useNavigate();

  const handleDelete = async () => {
    setIsDeleting(true);
    try {
      await fetch(`/api/items/${itemId}`, { method: 'DELETE' });
      navigate('/items', { replace: true });
    } catch (error) {
      setIsDeleting(false);
    }
  };

  return (
    <button onClick={handleDelete} disabled={isDeleting}>
      {isDeleting ? '削除中...' : '削除'}
    </button>
  );
}

17.2 よくあるアンチパターン

1. 不要な再レンダリングを引き起こすルート設計

// ❌ アンチパターン: ルートレベルで全体の state を管理
function App() {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  return (
    <BrowserRouter>
      {/* user や theme が変更されるたびに、全ルートが再レンダリングされる */}
      <Routes>
        <Route path="/" element={<Home user={user} />} />
        <Route path="/settings" element={<Settings theme={theme} setTheme={setTheme} />} />
      </Routes>
    </BrowserRouter>
  );
}

// ✅ 改善: Context やルートの loader で適切に分離
const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        index: true,
        element: <Home />,
        loader: () => getCurrentUser(), // ルートごとに必要なデータだけ取得
      },
      {
        path: 'settings',
        element: <Settings />,
        loader: () => getUserSettings(),
      },
    ],
  },
]);

2. useNavigate の不適切な使用

// ❌ アンチパターン: レンダリング中の navigate 呼び出し
function RedirectComponent() {
  const navigate = useNavigate();
  // レンダリング中に副作用を実行(React の strict mode で問題になる)
  navigate('/dashboard');
  return null;
}

// ✅ 改善: Navigate コンポーネントを使用
function RedirectComponent() {
  return <Navigate to="/dashboard" replace />;
}

// ✅ 改善: loader でリダイレクト
{
  path: 'old-page',
  loader: () => redirect('/new-page'),
}

17.3 v5 から v6/v7 への移行ガイド

React Router v5 から v6 への移行は、API の大幅な変更を伴う。段階的な移行が推奨される。

// ===== v5 のコード =====

// Switch → Routes に変更
import { Switch, Route } from 'react-router-dom';

<Switch>
  <Route exact path="/" component={Home} />
  <Route path="/users/:id" component={UserDetail} />
  <Route path="/users" component={UsersList} />
</Switch>

// ===== v6 への移行 =====

import { Routes, Route } from 'react-router-dom';

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/users/:id" element={<UserDetail />} />
  <Route path="/users" element={<UsersList />} />
</Routes>

主要な変更点の一覧:

v5v6/v7備考
<Switch><Routes>名称変更
<Route component={Comp}><Route element={<Comp />}>JSX を直接渡す
<Route render={() => ...}><Route element={...}>render prop 廃止
exact prop不要(デフォルトで完全一致)パスマッチングの改善
useHistory()useNavigate()API 変更
history.push('/path')navigate('/path')API 変更
history.replace('/path')navigate('/path', { replace: true })オプションで指定
history.goBack()navigate(-1)数値で履歴操作
<Redirect to="..."><Navigate to="..." replace>名称・挙動変更
match.paramsuseParams()フック必須
match.url相対パスを使用自動的に解決
ネストは手動<Outlet /> で自動レイアウト連携改善
// v5: useHistory
import { useHistory } from 'react-router-dom';

function LoginButton() {
  const history = useHistory();

  const handleLogin = () => {
    performLogin();
    history.push('/dashboard');
  };

  return <button onClick={handleLogin}>ログイン</button>;
}

// v6: useNavigate
import { useNavigate } from 'react-router-dom';

function LoginButton() {
  const navigate = useNavigate();

  const handleLogin = () => {
    performLogin();
    navigate('/dashboard');
  };

  return <button onClick={handleLogin}>ログイン</button>;
}
// v5: withRouter HOC
import { withRouter } from 'react-router-dom';

class OldComponent extends React.Component {
  handleClick = () => {
    this.props.history.push('/somewhere');
  };

  render() {
    return <button onClick={this.handleClick}>移動</button>;
  }
}

export default withRouter(OldComponent);

// v6: withRouter は廃止 → フックを使用
// クラスコンポーネントの場合はラッパーコンポーネントを作成
function OldComponentWrapper() {
  const navigate = useNavigate();
  return <OldComponent navigate={navigate} />;
}

17.4 段階的移行戦略

大規模プロジェクトでは、互換パッケージを利用した段階的な移行が推奨される。

# 互換パッケージのインストール
npm install react-router-dom@6 react-router-dom-v5-compat
// ステップ 1: 互換パッケージで v5 と v6 を共存させる
import { CompatRouter, CompatRoute } from 'react-router-dom-v5-compat';

function App() {
  return (
    <CompatRouter>
      {/* 既存の v5 ルート */}
      <Switch>
        <CompatRoute exact path="/" component={Home} />
        {/* 新しい v6 ルート */}
        <CompatRoute path="/new-feature" element={<NewFeature />} />
      </Switch>
    </CompatRouter>
  );
}

// ステップ 2: 一つずつ v6 の API に書き換える
// ステップ 3: 互換パッケージを削除し、完全に v6 に移行

18. まとめ

18.1 React Router の選択基準

React Router は、React アプリケーションにルーティング機能を導入する際の第一選択肢として、依然として最も信頼性の高いソリューションである。以下のマトリクスを参考に、プロジェクトに最適な構成を選択してほしい。

プロジェクト要件推奨構成
シンプルな SPAReact Router ライブラリモード(createBrowserRouter
データ駆動の SPAReact Router ライブラリモード + Data APIs(loader/action
フルスタック React アプリReact Router v7 フレームワークモード
Remix からの移行React Router v7 フレームワークモード(API 互換)
既存 v5 プロジェクト段階的移行(react-router-dom-v5-compat
型安全性を最優先TanStack Router も検討
SSR + SEO 重視Next.js も検討

18.2 学習ロードマップ

React Router を効果的に習得するための推奨学習パスを示す。

レベル 1: 基礎(1-2 日)
├── BrowserRouter / createBrowserRouter の基本
├── Route, Link, NavLink, Outlet の理解
├── useParams, useNavigate, useLocation
└── ネストされたルーティング

レベル 2: 中級(3-5 日)
├── Data APIs(loader, action)
├── Form コンポーネントと useFetcher
├── エラーハンドリング(errorElement, useRouteError)
├── 認証ガード(Protected Routes)
└── コード分割(lazy)

レベル 3: 上級(1-2 週間)
├── 最適化(defer, shouldRevalidate, prefetch)
├── テスト戦略(MemoryRouter, loader/action のテスト)
├── 大規模アプリケーションの設計
├── View Transitions API の統合
└── v7 フレームワークモードの理解

レベル 4: エキスパート
├── カスタムミドルウェアの実装
├── パフォーマンスチューニング
├── マイクロフロントエンドとの統合
└── 独自のルーティング抽象化の構築

18.3 今後の展望

React Router の将来像として、以下の方向性が見込まれる。

React Server Components(RSC)の完全統合: React Router v7 のフレームワークモードでは RSC のサポートが進んでおり、サーバーコンポーネントとクライアントコンポーネントのシームレスな統合が実現されつつある。これにより、初期ロード時のパフォーマンスとバンドルサイズの両面で大きな改善が期待できる。

型安全性のさらなる強化: v7 で導入された自動型生成(./+types/ ディレクトリ)により、loader のデータ型がコンポーネントの props に自動的に反映される。この方向性は今後さらに深化し、TanStack Router に匹敵する型安全性が実現される見込みである。

ミドルウェア API の安定化: 現在 unstable_middleware として提供されているミドルウェア機能が安定版として正式リリースされることで、認証・認可・ログ記録などの横断的関心事をより宣言的に処理できるようになる。

Web 標準への準拠: React Router は Request/Response/FormData といった Web 標準 API を積極的に活用しており、この方向性は今後も継続される。Web 標準に準拠することで、フレームワーク固有の知識ではなく、汎用的な Web 開発スキルとして蓄積できる。

18.4 参考リソース

本記事で解説した内容をさらに深く学習するための公式リソースを紹介する。

React Router は 10 年以上にわたって React エコシステムを支え続けてきた。v7 での Remix との統合により、クライアントサイドルーティングライブラリからフルスタック Web フレームワークへと進化した。ライブラリモードの手軽さとフレームワークモードの本格的な機能を、プロジェクトの成長に応じてシームレスに切り替えられる点は、React Router ならではの強みである。

本記事が、React Router を活用したモダンな React アプリケーション開発の一助となれば幸いである。