「React Router 使い方を実務レベルで理解したい」「BrowserRouterとcreateBrowserRouterの違いがいまいち分からない」「loader / action / errorElement / Outlet / lazyといったData Router系のAPIを、認証付きのSPAでどう組み合わせれば良いのか分からない」――この記事はそんな現役WebエンジニアのためのReact Router v7完全実践ガイドです。React Router v6.4で導入されたData Router(createBrowserRouter)はバージョン7でデフォルト構成となり、「ルーティングとデータ取得を一体で書く」という設計思想がついに標準化されました。本記事では、createBrowserRouterの基本から、RouterProvider・ネストルート・Outlet・loader / useLoaderData・action / useActionData・defer / Await / Suspense・errorElement / useRouteError・認証ガード・redirect helper・lazy ルート・パンくず実装、さらにNext.js App Router / TanStack Routerとの使い分け・テスト戦略・v7新機能までを、30本超のTypeScriptコードブロック・表3つ・FAQ6問で徹底解説。読み終えるころには、新規プロジェクトでReact Routerをどう選定し、どう設計し、どこまでSSRフレームワークに寄せるべきかを、自分の言葉で説明できるようになっているはずです。
- React Router v7の全体像〜なぜData Routerが標準になったのか
- インストールと最小構成
- ナビゲーション基本〜<Link> / <NavLink> / useNavigate
- 動的ルートとクエリパラメータ
- ネストルートとOutlet〜レイアウト共通化の決定打
- loader / useLoaderData〜データ取得をルートに紐付ける
- action / useActionData / <Form> 〜データ更新もルートで完結
- defer / Await / Suspense〜遅いAPIを並列化する
- エラーハンドリング〜errorElement / useRouteError
- 認証ガード〜loader内でリダイレクト
- lazy ルート〜コード分割で初回ロードを軽くする
- パンくず実装〜handle と useMatches
- Data Router vs Traditional Router 比較表
- 他ルーティング選択肢との比較
- テスト戦略〜MemoryRouterとcreateMemoryRouter
- v7新機能と「フレームワークモード」への道筋
- 実務での落とし穴と回避策
- パフォーマンスを引き出すTips集
- 主要APIチートシート〜どこで何を使うか
- よくある質問(FAQ)
- まとめ〜React Router v7をどう採用するか
React Router v7の全体像〜なぜData Routerが標準になったのか
React Routerは2014年から続く、Reactエコシステムでもっとも歴史の長いルーティングライブラリです。当初はコンポーネントベースの宣言的ルーティングを提供していましたが、v6.4(2022年)でcreateBrowserRouterを中心とする「Data Router」が導入され、ルーティングとデータ取得を一体で扱う設計に大きく舵を切りました。そしてv7(2024〜2026年)では、Data Routerが正式に第一級APIとなり、従来の<BrowserRouter>方式は「Traditional Router」として位置付け直されています。
Traditional Router(従来型)とData Routerの違い
v6.4以降の二系統を、機能差で並べると以下のように整理できます。
- ルート定義の場所: JSXツリー内(Traditional)vs アプリ外部の配列(Data)
- データ取得タイミング: コンポーネントマウント後(Traditional)vs ナビゲーション直前(Data)
- エラー境界: React標準ErrorBoundary(Traditional)vs errorElement(Data)
- フォーム送信: 自前fetch(Traditional)vs <Form> + action(Data)
- コード分割: React.lazy手動配置(Traditional)vs ルートのlazyプロパティ(Data)
Traditional Routerは、<BrowserRouter>と<Routes>をコンポーネントツリーに直接書く方式です。シンプルですが、データ取得は各ページコンポーネントのuseEffect内で行うため、ナビゲーション完了後にfetchが走る「ウォーターフォール」が発生しがちでした。一方Data Routerは、ルート定義にloader関数を紐付け、ナビゲーションと同時並行でデータ取得を行う設計です。Next.jsやRemixのgetServerSidePropsに近い思想で、クライアントSPAでも同じ恩恵を受けられます。
// Traditional Router: useEffectでデータ取得 → ウォーターフォール発生
import { BrowserRouter, Routes, Route } from "react-router-dom";
function UserPage() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
// ナビゲーション完了後にfetchが走る(遅い)
fetch("/api/user").then((r) => r.json()).then(setUser);
}, []);
if (!user) return <p>Loading...</p>;
return <h1>{user.name}</h1>;
}
export function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/user" element={<UserPage />} />
</Routes>
</BrowserRouter>
);
}
// Data Router: loaderでナビゲーション中にfetch → 並列化
import { createBrowserRouter, RouterProvider, useLoaderData } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/user",
loader: async () => {
// ナビゲーション完了前にfetchが走る(速い)
const res = await fetch("/api/user");
return res.json() as Promise<User>;
},
element: <UserPage />,
},
]);
function UserPage() {
const user = useLoaderData() as User;
return <h1>{user.name}</h1>;
}
export function App() {
return <RouterProvider router={router} />;
}
v7で変わったこと〜Remixとの統合と新機能
v7の最大の変化は、RemixがReact Routerに統合されたことです。これによりReact Routerは、「ライブラリモード(SPA)」「フレームワークモード(SSR)」の2つの顔を持つようになりました。本記事はライブラリモード(クライアントSPA)を中心に扱いますが、フレームワークモードへの移行パスも意識した設計を紹介します。型安全なルート定義、より高度なdefer/Awaitパターン、clientLoader / clientActionといった新APIも本記事の後半でカバーします。
インストールと最小構成
npm / pnpm / yarnでのインストール
本体のインストールはワンライナーで終わります。React 18以降・TypeScript 5系前提で、次のいずれかを実行してください。パッケージ名はreact-router-domのままで変わっていません(将来はreact-routerに統合予定ですが、v7時点では現状維持)。
# npm
npm install react-router-dom
# pnpm
pnpm add react-router-dom
# yarn
yarn add react-router-dom
# TypeScript型は同梱なので追加インストール不要
# Viteで雛形を作る場合
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install react-router-dom
最小構成: createBrowserRouter + RouterProvider
v7時代の最小構成はとてもシンプルです。createBrowserRouterでルート配列を渡し、RouterProviderでアプリにマウントします。<BrowserRouter>でJSXツリーをラップしていた従来型と比べ、ルート定義がツリーから独立するのが思想的なポイントです。TypeScript完全実践ガイドで扱ったsatisfies演算子も、ルート定義の型安全化に有効です。
// src/main.tsx 〜 アプリのエントリポイント
import React from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);
// src/router.tsx 〜 ルート定義を一箇所に集約
import { createBrowserRouter } from "react-router-dom";
import { Root } from "./routes/Root";
import { HomePage } from "./routes/HomePage";
import { AboutPage } from "./routes/AboutPage";
import { NotFoundPage } from "./routes/NotFoundPage";
export const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <NotFoundPage />,
children: [
{ index: true, element: <HomePage /> },
{ path: "about", element: <AboutPage /> },
],
},
]);
ナビゲーション基本〜<Link> / <NavLink> / useNavigate
<Link>でSPAナビゲーション
ページ遷移は<a>タグではなく、必ず<Link>を使います。<a>はブラウザに完全リロードを要求してしまい、SPAの利点(クライアント状態保持・高速遷移)を失うからです。to propには絶対パス・相対パス・オブジェクト形式すべてが渡せます。
// Link基本パターン
import { Link } from "react-router-dom";
export function Header() {
return (
<nav>
<Link to="/">ホーム</Link>
<Link to="/about">About</Link>
<Link to="/users/42">User #42</Link>
{/* オブジェクト形式: search/hashも一緒に渡せる */}
<Link
to={{ pathname: "/search", search: "?q=react", hash: "#results" }}
>
検索結果
</Link>
</nav>
);
}
<NavLink>でアクティブ状態を表示
<NavLink>は、現在のURLとマッチしているときに自動でクラスやスタイルを切り替えてくれる、ナビゲーション専用版の<Link>です。タブUIやサイドメニューで「いまここ」を視覚化するのに必須のコンポーネントです。
// NavLink: アクティブ判定を関数で受け取る
import { NavLink } from "react-router-dom";
export function SideNav() {
return (
<aside>
<NavLink
to="/dashboard"
className={({ isActive }) =>
isActive ? "nav-item nav-item--active" : "nav-item"
}
>
ダッシュボード
</NavLink>
<NavLink
to="/settings"
style={({ isActive }) => ({
fontWeight: isActive ? "bold" : "normal",
color: isActive ? "#0066ff" : "#333",
})}
>
設定
</NavLink>
</aside>
);
}
useNavigateでプログラム的に遷移
ボタンクリック後・フォーム送信後・タイマー経過後など、JS側から能動的にページ遷移したいときはuseNavigateを使います。navigate(-1)で「戻る」、{ replace: true }で履歴を置き換える挙動も指定可能です。
// useNavigate: プログラム的遷移と履歴制御
import { useNavigate } from "react-router-dom";
export function LoginButton() {
const navigate = useNavigate();
const handleLogin = async () => {
await fakeLogin();
// ログイン後にダッシュボードへ(履歴は残す)
navigate("/dashboard");
};
const handleLogout = () => {
// ログアウト時はトップに戻し、履歴も置き換える(戻るで戻れない)
navigate("/", { replace: true });
};
return (
<>
<button onClick={handleLogin}>ログイン</button>
<button onClick={handleLogout}>ログアウト</button>
<button onClick={() => navigate(-1)}>戻る</button>
</>
);
}
動的ルートとクエリパラメータ
useParamsで動的セグメントを受け取る
URLに含まれる動的な値(例: /users/42の42)を受け取るには、:プレフィックス付きのパスとuseParamsを組み合わせます。TypeScriptではジェネリック引数で型を指定できますが、値は常にstring | undefinedな点に注意してください。
// 動的ルート定義
import { createBrowserRouter } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/users/:userId",
element: <UserDetail />,
},
{
path: "/posts/:category/:slug",
element: <PostDetail />,
},
]);
// useParams: 動的セグメントを受け取る
import { useParams } from "react-router-dom";
export function UserDetail() {
// 型引数で形を明示できるが、値はすべて string | undefined
const { userId } = useParams<{ userId: string }>();
if (!userId) return <p>userIdが指定されていません</p>;
// 数値化は呼び出し側で行う
const id = Number(userId);
if (Number.isNaN(id)) return <p>userIdが数値ではありません</p>;
return <h1>User #{id}</h1>;
}
export function PostDetail() {
const { category, slug } = useParams<{ category: string; slug: string }>();
return (
<article>
<h1>[{category}] {slug}</h1>
</article>
);
}
useSearchParamsでクエリパラメータ操作
クエリパラメータ(?q=react&page=2)はuseSearchParamsで読み書きできます。返り値はWeb標準のURLSearchParamsと同じインターフェースなので、Reactを離れても同じ知識が使えます。検索フォームのフィルタ状態をURLに持たせると、共有・ブックマーク・戻る進むのすべてが自然に機能します。
// useSearchParams: クエリパラメータの読み書き
import { useSearchParams } from "react-router-dom";
export function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const q = searchParams.get("q") ?? "";
const page = Number(searchParams.get("page") ?? "1");
return (
<div>
<input
value={q}
onChange={(e) => {
// 既存パラメータを保ちつつ q だけ更新
setSearchParams((prev) => {
prev.set("q", e.target.value);
prev.set("page", "1"); // 検索条件変更時はpageをリセット
return prev;
});
}}
/>
<p>検索ワード: {q} / ページ: {page}</p>
<button onClick={() => setSearchParams({ q, page: String(page + 1) })}>
次のページ
</button>
</div>
);
}
ネストルートとOutlet〜レイアウト共通化の決定打
Outletで子ルートを差し込む
共通ヘッダー・サイドナビを持つレイアウトを作るときの定番がネストルート + Outletです。親ルートにレイアウトコンポーネントを置き、<Outlet />の位置に子ルートのelementが差し込まれます。これにより、ページ遷移してもレイアウトはアンマウントされず、レイアウト内のスクロール位置・状態が保持されます。
// レイアウトコンポーネント(親ルート)
import { Outlet, Link } from "react-router-dom";
export function DashboardLayout() {
return (
<div className="dashboard">
<aside className="sidebar">
<Link to="/dashboard">概要</Link>
<Link to="/dashboard/analytics">分析</Link>
<Link to="/dashboard/settings">設定</Link>
</aside>
<main>
{/* ここに子ルートが描画される */}
<Outlet />
</main>
</div>
);
}
// ネストルート定義
const router = createBrowserRouter([
{
path: "/dashboard",
element: <DashboardLayout />,
children: [
{ index: true, element: <DashboardOverview /> }, // /dashboard
{ path: "analytics", element: <Analytics /> }, // /dashboard/analytics
{ path: "settings", element: <Settings /> }, // /dashboard/settings
],
},
]);
index ルートでデフォルト表示を作る
子ルート配列の中で{ index: true }を指定すると、親パスと完全一致したとき(例: /dashboardそのまま)に表示される子ルートを定義できます。タブ型UIの「最初に開いたとき何を見せるか」を決めるのに便利です。
loader / useLoaderData〜データ取得をルートに紐付ける
loader関数の基本
Data Routerの真骨頂がloaderです。ルート定義に紐付けた関数は、そのルートにナビゲートする「直前」に実行され、戻り値がuseLoaderDataで取り出せます。useEffect + fetch + setStateのお決まりパターンが消え、コンポーネントは「データを描画するだけ」のシンプルさを保てます。
// loader: ルートにデータ取得を紐付ける
import { createBrowserRouter, useLoaderData } from "react-router-dom";
type User = { id: number; name: string; email: string };
const router = createBrowserRouter([
{
path: "/users/:userId",
loader: async ({ params }) => {
// params は string 型で渡ってくる
const res = await fetch(`/api/users/${params.userId}`);
if (!res.ok) throw new Response("Not Found", { status: 404 });
return (await res.json()) as User;
},
element: <UserDetail />,
},
]);
function UserDetail() {
// useLoaderDataはunknownを返すのでasで絞り込む
const user = useLoaderData() as User;
return (
<article>
<h1>{user.name}</h1>
<p>{user.email}</p>
</article>
);
}
型安全なloaderヘルパーを用意する
useLoaderData() as Fooのasキャストは型安全とは言いがたいので、実務では型推論を効かせる薄いラッパーを1つ用意するのが定石です。TypeScriptの型推論を最大限活用しましょう。
// 型推論を効かせるloaderヘルパー
import type { LoaderFunction } from "react-router-dom";
import { useLoaderData as useLoaderDataOriginal } from "react-router-dom";
// loaderの戻り値を推論する型ユーティリティ
type LoaderData<T extends LoaderFunction> = Awaited<ReturnType<T>>;
export function useTypedLoaderData<T extends LoaderFunction>() {
return useLoaderDataOriginal() as LoaderData<T>;
}
// 使い方
const userLoader = (async ({ params }) => {
const res = await fetch(`/api/users/${params.userId}`);
return (await res.json()) as User;
}) satisfies LoaderFunction;
function UserDetail() {
const user = useTypedLoaderData<typeof userLoader>();
// user は User 型に推論される
return <h1>{user.name}</h1>;
}
useNavigationでローディング状態を取る
loader実行中(=ナビゲーション中)はuseNavigationでグローバルな状態を取得できます。ページ全体のローディングバーや、リンクのスピナー表示に使います。stateは"idle" | "loading" | "submitting"の3値です。
// useNavigation: グローバルなローディング状態
import { useNavigation } from "react-router-dom";
export function GlobalSpinner() {
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
return isLoading ? <div className="global-spinner">Loading...</div> : null;
}
action / useActionData / <Form> 〜データ更新もルートで完結
actionとForm: HTMLフォームの拡張
POST / PUT / DELETEといったデータ更新もルート定義のaction関数で扱えます。React Routerの<Form>はHTMLの<form>を拡張したコンポーネントで、submitをaction関数にディスパッチしてくれます。preventDefaultやfetchを書く必要がなく、JSが無効な環境でも動く(プログレッシブエンハンスメント)のが特徴です。
// action: フォーム送信を処理する
import { Form, useActionData, type ActionFunction } from "react-router-dom";
type CreatePostResult = { ok: true; id: string } | { ok: false; error: string };
const createPostAction = (async ({ request }) => {
const formData = await request.formData();
const title = String(formData.get("title") ?? "");
if (title.length < 3) {
return { ok: false, error: "タイトルは3文字以上" } satisfies CreatePostResult;
}
const res = await fetch("/api/posts", { method: "POST", body: formData });
const json = await res.json();
return { ok: true, id: json.id } satisfies CreatePostResult;
}) satisfies ActionFunction;
export const newPostRoute = {
path: "/posts/new",
action: createPostAction,
element: <NewPostForm />,
};
function NewPostForm() {
const result = useActionData() as CreatePostResult | undefined;
return (
<Form method="post">
<input name="title" />
<button type="submit">作成</button>
{result && !result.ok && <p>{result.error}</p>}
{result?.ok && <p>作成しました: {result.id}</p>}
</Form>
);
}
useFetcherで非ナビゲーションフォーム
「いいねボタン」のようにフォーム送信後もページ遷移したくない場合は、useFetcherを使います。fetcher.FormはsubmitしてもURLが変わらず、actionの結果だけがfetcher.dataに入ります。useMemo vs useCallbackで議論したような細かい状態管理を、ライブラリ任せにできるのが大きい利点です。
// useFetcher: ページ遷移なしのaction呼び出し
import { useFetcher } from "react-router-dom";
export function LikeButton({ postId }: { postId: string }) {
const fetcher = useFetcher<{ likes: number }>();
const isSubmitting = fetcher.state === "submitting";
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "送信中..." : "いいね"}
</button>
{fetcher.data && <span>現在 {fetcher.data.likes} likes</span>}
</fetcher.Form>
);
}
useSubmitで手動submit
JS側からプログラム的にactionを叩きたい場合はuseSubmitを使います。フォームの値を渡すことも、FormDataを組み立てて渡すことも可能です。
// useSubmit: JSからactionを発火
import { useSubmit } from "react-router-dom";
export function AutoSaveDraft({ draftId }: { draftId: string }) {
const submit = useSubmit();
const handleSave = (body: string) => {
const formData = new FormData();
formData.set("body", body);
submit(formData, { method: "post", action: `/drafts/${draftId}/save` });
};
return <button onClick={() => handleSave("...")}>保存</button>;
}
defer / Await / Suspense〜遅いAPIを並列化する
deferで「先に画面・遅れてデータ」
loaderの中でawaitしてしまうと、全データが揃うまで画面遷移が遅延します。重要度の低いデータ(おすすめ・コメント等)を遅延させたい場合は、deferでPromiseのまま返し、コンポーネント側で<Await>と<Suspense>を使って描画を切り分けます。
// defer: 重要データだけ待ち、それ以外は遅延配信
import { defer, type LoaderFunction } from "react-router-dom";
const productLoader = (async ({ params }) => {
// 商品本体は必須なのでawaitする
const product = await fetch(`/api/products/${params.id}`).then((r) => r.json());
// レビューは重いので待たずに返す
const reviewsPromise = fetch(`/api/products/${params.id}/reviews`).then((r) =>
r.json()
);
return defer({ product, reviews: reviewsPromise });
}) satisfies LoaderFunction;
// Await + Suspense でストリーミング描画
import { Await, useLoaderData } from "react-router-dom";
import { Suspense } from "react";
function ProductDetail() {
const { product, reviews } = useLoaderData() as {
product: Product;
reviews: Promise<Review[]>;
};
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<Suspense fallback={<p>レビューを読み込み中...</p>}>
<Await
resolve={reviews}
errorElement={<p>レビューの取得に失敗しました</p>}
>
{(resolved: Review[]) => (
<ul>
{resolved.map((r) => (
<li key={r.id}>{r.text}</li>
))}
</ul>
)}
</Await>
</Suspense>
</div>
);
}
エラーハンドリング〜errorElement / useRouteError
errorElementで各ルートのエラー画面を分離
loader / action / コンポーネント描画中に投げられた例外は、最も近い親ルートのerrorElementに伝播します。React標準のError Boundaryと違い、非同期エラー(loader内のthrow)も拾えるのが特徴です。
// errorElement: ルートごとのエラー境界
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <RootErrorPage />,
children: [
{
path: "users/:userId",
loader: userLoader,
element: <UserDetail />,
// ユーザー詳細だけ別エラー画面を出したい場合
errorElement: <UserErrorPage />,
},
],
},
]);
// useRouteError: 投げられたエラーを取り出す
import { useRouteError, isRouteErrorResponse } from "react-router-dom";
export function UserErrorPage() {
const error = useRouteError();
// loaderで throw new Response(..., {status:404}) した場合
if (isRouteErrorResponse(error)) {
if (error.status === 404) return <p>ユーザーが見つかりません</p>;
if (error.status === 401) return <p>ログインが必要です</p>;
return <p>{error.status} {error.statusText}</p>;
}
// 普通のError
if (error instanceof Error) return <p>エラー: {error.message}</p>;
return <p>不明なエラーが発生しました</p>;
}
認証ガード〜loader内でリダイレクト
redirect helperで未認証時に飛ばす
「ログイン必須のページ」を守るのに、わざわざHOC(Higher Order Component)やAuthProviderを書く必要はありません。loaderの中で認証チェックし、未認証ならredirectを投げるのが最もシンプルでバグの少ないやり方です。loaderはコンポーネント描画より早く実行されるため、未認証ユーザーが一瞬でも守られたページを見ることがないのがポイントです。
// 認証ガードloader
import { redirect, type LoaderFunctionArgs } from "react-router-dom";
export async function requireAuth({ request }: LoaderFunctionArgs) {
const user = await fetchCurrentUser();
if (!user) {
// 元のURLを保持して、ログイン後に戻ってこられるようにする
const url = new URL(request.url);
throw redirect(`/login?from=${encodeURIComponent(url.pathname)}`);
}
return user;
}
// 使い方: 守りたい全ルートのloaderから呼ぶ
const router = createBrowserRouter([
{
path: "/dashboard",
loader: requireAuth, // ここで認証チェック
element: <Dashboard />,
},
{
path: "/settings",
loader: async (args) => {
const user = await requireAuth(args);
// 認証OKなら追加データも取る
const settings = await fetch("/api/settings").then((r) => r.json());
return { user, settings };
},
element: <Settings />,
},
]);
認証ガード設計の原則
ガード実装で迷ったときに思い出してほしい原則は次の5つです。
- 「描画してから守る」のではなく「描画前に守る」: loaderでチェックすることで未認証ユーザーに守られたUIを一瞬たりとも見せない
- 元URLは必ずクエリで保持: ログイン後にユーザーが意図したページに戻せる(
?from=...) - 権限チェックはルート単位で粒度を分ける: 「ログイン要」と「管理者要」は別レイヤーのloaderに
- useContextの認証情報はUI表示専用: 「ガード」と「表示」は責務を分けると保守が楽になる
- 403と401を取り違えない: 未ログインは
/loginへredirect、ログイン済だが権限なしはerrorElementで403表示
レイアウト単位で一括認証ガード
複数のページに認証チェックをかけたい場合は、親ルートのloaderで1回だけチェックして子ルートをまとめて守ります。子ルートのloaderから親ルートのデータを参照したい場合はuseRouteLoaderData(routeId)を使います。
// 親ルートで一括認証 + ID で参照可能にする
const router = createBrowserRouter([
{
id: "protected", // 子から参照するためのid
path: "/",
loader: requireAuth,
element: <ProtectedLayout />,
children: [
{ path: "dashboard", element: <Dashboard /> },
{ path: "settings", element: <Settings /> },
{ path: "profile", element: <Profile /> },
],
},
{ path: "/login", element: <LoginPage /> },
]);
// 子ルートから親loaderの結果を取り出す
import { useRouteLoaderData } from "react-router-dom";
function Profile() {
const user = useRouteLoaderData("protected") as User;
return <h1>{user.name}さんのプロフィール</h1>;
}
lazy ルート〜コード分割で初回ロードを軽くする
lazyプロパティで遅延ロード
大規模アプリでは、すべてのページを初回JSバンドルに含めるとTime To Interactive(TTI)が悪化します。React Router v7では各ルート定義にlazyプロパティを付けるだけで、そのルートにアクセスされるまで対応するモジュールのロードを遅延できます。モダンビルドツール&Webパフォーマンス最適化完全ガイドで扱ったコード分割の文脈を、ルーティング層で完結させられます。
// lazy: ルート単位のコード分割
const router = createBrowserRouter([
{
path: "/dashboard",
// Component / loader / action / errorElementをまとめて遅延ロード
lazy: async () => {
const mod = await import("./routes/Dashboard");
return {
Component: mod.Dashboard,
loader: mod.dashboardLoader,
};
},
},
{
path: "/reports",
lazy: () => import("./routes/Reports").then((m) => ({
Component: m.Reports,
loader: m.reportsLoader,
action: m.reportsAction,
})),
},
]);
パンくず実装〜handle と useMatches
各ルートにhandleでメタ情報を持たせる
各ルートに任意のメタ情報(パンくず文字列・タイトル等)を持たせたい場合は、handleプロパティに任意のオブジェクトを置きます。useMatchesで現在マッチしている全ルートを取得できるので、これを使ってパンくずを自動生成できます。
// handle: 各ルートにメタ情報を埋め込む
type RouteHandle = {
breadcrumb: (data: unknown) => string;
};
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
handle: { breadcrumb: () => "ホーム" } satisfies RouteHandle,
children: [
{
path: "users",
element: <UserList />,
handle: { breadcrumb: () => "ユーザー一覧" } satisfies RouteHandle,
children: [
{
path: ":userId",
loader: userLoader,
element: <UserDetail />,
handle: {
breadcrumb: (data: unknown) => `${(data as User).name}さん`,
} satisfies RouteHandle,
},
],
},
],
},
]);
// useMatchesでパンくずを自動生成
import { Link, useMatches } from "react-router-dom";
export function Breadcrumbs() {
const matches = useMatches();
const crumbs = matches
.filter((m) => (m.handle as RouteHandle | undefined)?.breadcrumb)
.map((m) => ({
to: m.pathname,
label: (m.handle as RouteHandle).breadcrumb(m.data),
}));
return (
<nav aria-label="breadcrumb">
<ol>
{crumbs.map((c, i) => (
<li key={c.to}>
{i < crumbs.length - 1 ? <Link to={c.to}>{c.label}</Link> : c.label}
</li>
))}
</ol>
</nav>
);
}
Data Router vs Traditional Router 比較表
同じReact Routerでも、2つのモードで設計思想がかなり違います。新規開発は基本的にData Router一択ですが、レガシー保守の文脈では従来型の知識も必要です。
<table border="1" cellpadding="6" cellspacing="0">
他ルーティング選択肢との比較
Next.js App Routerとの使い分け
「Next.jsじゃダメなの?」という質問はよく受けます。SSR / SSG / Edge Runtimeまで含めた統合体験が必要ならNext.js App Routerが圧倒的に有利です。しかし、クライアントSPAだけで完結する管理画面・社内ツール・Electron系では、Next.jsはオーバースペックで、React RouterのData Routerのほうがはるかに軽量・シンプルです。バックエンドが別言語(Go・Rails・Laravel等)で確定している現場でも、React Routerは強い選択肢です。
TanStack Routerとの違い
TanStack Routerは「型安全に全振り」した新興ライブラリで、ルートパス・パラメータ・search paramsまですべて静的型チェックで検証できる点が大きな強みです。React Routerもv7で型サポートを強化していますが、厳密な型安全を最優先するならTanStack Router、Remixエコシステム・SSR移行の余地を残したいならReact Router、と棲み分けてください。
<table border="1" cellpadding="6" cellspacing="0">
テスト戦略〜MemoryRouterとcreateMemoryRouter
createMemoryRouterでloaderごとテスト
Data Routerをテストする場合は、URL履歴をメモリ上に持つcreateMemoryRouterを使います。loader / action もそのまま実行できるため、「特定のURLに遷移したらloaderが正しいデータを返し、UIが正しく描画される」というシナリオを丸ごとテストできます。
// createMemoryRouter + Testing Library
import { render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
import { describe, it, expect } from "vitest";
import { userRoutes } from "./routes/user";
describe("User detail page", () => {
it("renders user name from loader", async () => {
const router = createMemoryRouter(userRoutes, {
initialEntries: ["/users/42"],
});
render(<RouterProvider router={router} />);
expect(await screen.findByText("User #42")).toBeInTheDocument();
});
it("redirects to /login when not authenticated", async () => {
// requireAuth が redirect("/login") を投げるシナリオ
const router = createMemoryRouter(protectedRoutes, {
initialEntries: ["/dashboard"],
});
render(<RouterProvider router={router} />);
expect(await screen.findByText(/ログイン/)).toBeInTheDocument();
});
});
v7新機能と「フレームワークモード」への道筋
clientLoader / clientAction
v7では、サーバー側で動くloader / actionに加え、ブラウザ側だけで動くclientLoader / clientActionが追加されました。ライブラリモード(SPA)ではclientLoaderとloaderはほぼ同じですが、フレームワークモードに移行した瞬間にこの分離が効いてきます。将来SSRに引っ越す可能性があるコードは、最初からclientLoaderで書いておくと移行コストが下がります。
// clientLoader: クライアント専用ローダー
export const clientLoader = async ({ params }: LoaderFunctionArgs) => {
// localStorageなどブラウザ専用APIに依存する処理はここに
const cached = localStorage.getItem(`user-${params.userId}`);
if (cached) return JSON.parse(cached);
const res = await fetch(`/api/users/${params.userId}`);
const user = await res.json();
localStorage.setItem(`user-${params.userId}`, JSON.stringify(user));
return user;
};
型生成: generateRouteTypes
v7にはCLIベースの型ジェネレータも同梱されており、ルート定義からパス・パラメータの型を自動生成できます。typescript付きsatisfiesと組み合わせれば、TanStack Routerに迫る型安全性が手に入ります。
実務での落とし穴と回避策
- loader内でstateを参照しない: loaderはコンポーネントの外で実行されるため、Reduxストアや
useContextの値は直接参照できません。グローバルなアクセス用にプレーンなオブジェクトを別途用意するか、Zustandのような外部storeを使ってください。 - loaderの戻り値は必ずシリアライズ可能に: クラスインスタンス・関数・
Date等を直接返すと、フレームワークモードに移行した瞬間にエラーになります。プレーンなオブジェクト・ISO文字列・数値・配列のみを返しましょう。 - useNavigateを依存配列に入れない: 一見useEffectの依存配列に入れたくなりますが、
navigate関数は毎レンダリングで参照が変わる可能性があるため、無限ループの原因になります。useEffect完全ガイドの依存配列ルールを思い出してください。 - Form内のonSubmitでpreventDefaultしない: React Routerの
<Form>は自前でsubmitを横取りしています。onSubmitを上書きするときはnavigation.formAction等を尊重する設計にしてください。 - Strict Mode下でloaderが2回走る: 開発時のReact Strict Modeでは、副作用検出のためloaderが2回呼ばれることがあります。本番ビルドでは1回なので、loader内に副作用(POST送信等)を書かないようにしましょう。
パフォーマンスを引き出すTips集
ルーティング層は、見落とすと体感速度を大きく削るボトルネックになりがちです。最低限押さえておきたいのが次の5点です。
- loaderでawaitを並列化:
await Promise.all([fetchA(), fetchB()])で複数fetchを並列に。先頭からawaitを書き連ねるとウォーターフォール化する - 非クリティカルデータはdefer + Await: ファーストビューに不要なデータはdeferでPromiseのまま返し、Suspenseで段階的に描画
- lazyでルート分割: 管理画面・レアな機能ページは初回バンドルから除外
- useFetcherでナビゲーション抑制: いいね・お気に入り等の小さな更新はuseFetcherでURLを変えない
- shouldRevalidateで再fetchを抑制: 親ルートに戻った時のloader再実行が不要なら
shouldRevalidateでfalseを返す
主要APIチートシート〜どこで何を使うか
本記事で扱ったAPIを「いつ・どこで使うか」の観点で整理します。新人メンバーへの引き継ぎ・コードレビュー時のチェックリストとしても使えます。
<table border="1" cellpadding="6" cellspacing="0">
よくある質問(FAQ)
Q1. BrowserRouterのままでも問題ない?
動作はしますが、loader / action / errorElement等の現代的APIは使えません。新規プロジェクトはcreateBrowserRouter一択です。既存プロジェクトの移行は、まず最外周をRouterProviderに置き換え、ページ単位で順次loaderへ移植するのが現実的です。
Q2. React Router v7とRemixはどっちを学ぶべき?
v7時点でRemixはReact Routerに統合済みです。「フレームワークモード」のReact Router = 旧Remixと理解してOKです。学ぶ順序としては、まずライブラリモード(本記事)→ フレームワークモード(SSR・サーバーローダー)が無理がありません。
Q3. SSRが必要になったらNext.jsに移行すべき?
条件次第ですが、「すでにReact Routerで書いたSPA」を最小コストでSSR化したいのであれば、Next.jsへの移行よりも、React Routerのフレームワークモードに引き上げるほうが圧倒的に低コストです。逆に、画像最適化・ISR・Edge Runtime・App Routerのキャッシュ階層を活用したいならNext.js移行を検討してください。
Q4. 認証はContextとloaderどっちで持つべき?
状態を「画面に表示する」ためにはuseContext(またはZustand等)、「ルートを守る」ためにはloader内のrequireAuth、と役割を分けるのが定石です。loaderはコンポーネント描画より前に走るので、未認証ユーザーを描画前にリダイレクトできます。
Q5. ページ間で巨大なデータをキャッシュしたい
React Router自体にもloaderの結果キャッシュはありますが、本格的なクエリキャッシュ・楽観的更新・無限スクロールを扱うなら、TanStack Query等の専用ライブラリと併用してください。loader内でqueryClient.fetchQuery()を呼ぶハイブリッド構成が、現状もっとも実用的です。
Q6. パスのタイポを型で検出したい
v7の型生成機能を有効化すると、ルート定義から自動生成された型をLink to=に補完させられます。それでも不足なら、TanStack Routerへの移行を検討する価値があります。現実的な落としどころは、パス定数を1ファイルに集約してそれだけ使う方式です(例: ROUTES.userDetail(42)のようなビルダー関数)。
まとめ〜React Router v7をどう採用するか
React Router v7は、もはや「単なるルーター」ではなく、「ルーティング + データ取得 + フォーム送信 + エラーハンドリングを統合したクライアントランタイム」です。createBrowserRouterを起点に、loader / action / errorElement / Outlet / lazy / handleを組み合わせれば、Next.jsをわざわざ持ち込まなくても、堅牢なSPAを少ない記述で構築できます。本記事の30本超のコードと比較表・FAQを土台に、ぜひ手元のプロジェクトで実装を試してみてください。React Hooks完全実践ガイド・React状態管理ライブラリ完全比較・TypeScript完全実践ガイドと合わせて読めば、現代SPAの設計思想を一通り押さえられるはずです。

コメント