第1回では、React Router v7の基本的なコンセプトを解説しました。この記事では、実際のプロダクト開発で必要になる応用テクニックを扱います。loader/action を使ったData mode、Protected Routes、Lazy Loadingがメインテーマです。
前提: 第1回の内容(
BrowserRouter、Routes/Route、Link/NavLink、useParams、useNavigate)を理解していること
Data mode — ルートにデータ取得を直接紐付ける
なぜData modeが必要か
宣言的なルーティング(第1回のスタイル)でよく起きる問題がウォーターフォールローディングです。
通常のパターンでは、コンポーネントがレンダリングされた後に useEffect でデータ取得が始まります。親コンポーネントのデータ取得が完了して初めて子コンポーネントがレンダリングされ、子コンポーネントがまたデータ取得を開始する——という連鎖が発生し、ページの表示が遅くなります。
Data modeは、ルート定義に loader を宣言することでこの問題を解決します。React Routerはいずれかのコンポーネントをレンダリングする前に、すべての loader を並列実行します。
createBrowserRouter によるセットアップ
// main.tsx
import { createBrowserRouter, RouterProvider } from 'react-router'
import { createRoot } from 'react-dom/client'
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{
index: true,
element: <Home />,
},
{
path: 'users',
element: <UserList />,
loader: async () => {
const res = await fetch('/api/users')
if (!res.ok) throw new Response('Server Error', { status: 500 })
return res.json()
},
},
{
path: 'users/:userId',
element: <UserDetail />,
loader: async ({ params }) => {
const res = await fetch(`/api/users/${params.userId}`)
if (!res.ok) throw new Response('Not Found', { status: 404 })
return res.json()
},
},
],
},
])
createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />
)
Data modeでは <BrowserRouter> + <Routes> の代わりに、createBrowserRouter でルートツリーをJavaScriptオブジェクトとして定義し、<RouterProvider> に渡します。
useLoaderData でデータを受け取る
// pages/UserList.tsx
import { useLoaderData, Link } from 'react-router'
interface User {
id: number
name: string
email: string
}
export default function UserList() {
// コンポーネントのレンダリング前にデータ取得が完了している
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>
)
}
useEffect との比較:
// 従来のパターン — レンダリング後にデータ取得が始まる
export default function UserList() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/users')
.then(r => r.json())
.then(data => {
setUsers(data)
setLoading(false)
})
}, [])
if (loading) return <div>読み込み中...</div>
return <ul>...</ul>
}
// loaderを使う場合 — レンダリング時点でデータが揃っている
export default function UserList() {
const users = useLoaderData() as User[]
return <ul>...</ul>
}
ローディング状態の管理、useEffect、レースコンディションの考慮がすべて不要になります。
action — フォームの送信処理
action は loader と対になる概念です。loader がデータの読み取り(GET)を担当するのに対し、action はデータの書き込み(POST、PUT、DELETE)を担当します。
// ルート定義
import { redirect } from 'react-router'
{
path: 'users/new',
element: <CreateUser />,
action: async ({ request }) => {
const formData = await request.formData()
const payload = {
name: formData.get('name'),
email: formData.get('email'),
}
const res = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
// エラーをコンポーネント側で受け取れるよう返す
return { error: 'ユーザーの作成に失敗しました。もう一度お試しください。' }
}
// 作成成功後にリダイレクト
return redirect('/users')
},
}
// pages/CreateUser.tsx
import { Form, useActionData, useNavigation } from 'react-router'
interface ActionData {
error?: string
}
export default function CreateUser() {
const actionData = useActionData() as ActionData | undefined
const navigation = useNavigation()
const isSubmitting = navigation.state === 'submitting'
return (
// 通常の <form> ではなく React Router の <Form> を使う
<Form method="post">
<div>
<label htmlFor="name">名前</label>
<input id="name" name="name" required />
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input id="email" name="email" type="email" required />
</div>
{actionData?.error && (
<p style={{ color: 'red' }}>{actionData.error}</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '作成中...' : 'ユーザーを作成'}
</button>
</Form>
)
}
React Routerの <Form method="post"> は、通常のHTTPリクエストではなく現在のルートの action を呼び出します。ページはリロードされず、ステートも維持されます。
エラーハンドリングとローディング表示
ルート単位のエラーハンドリング:
// components/RouteError.tsx
import { useRouteError, isRouteErrorResponse, Link } from 'react-router'
export default function RouteError() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div>
<h1>404 — ページが見つかりません</h1>
<Link to="/">トップへ戻る</Link>
</div>
)
}
return <h1>エラー {error.status}: {error.statusText}</h1>
}
return <h1>予期しないエラーが発生しました</h1>
}
// ルート定義に errorElement を追加
{
path: 'users/:userId',
element: <UserDetail />,
loader: userDetailLoader,
errorElement: <RouteError />, // loaderがthrowするか、コンポーネントがクラッシュしたときに表示
}
グローバルローディングインジケーター:
// layouts/RootLayout.tsx
import { Outlet, useNavigation } from 'react-router'
export default function RootLayout() {
const navigation = useNavigation()
// navigation.state: 'idle' | 'loading' | 'submitting'
const isLoading = navigation.state === 'loading'
return (
<div>
{/* ページ遷移中にローディングバーを表示 */}
{isLoading && <div className="global-loading-bar" />}
<header>...</header>
<main>
<Outlet />
</main>
</div>
)
}
useNavigation をルートレイアウトに置くことで、ページ単位でローディング状態を個別管理する手間がなくなります。
Protected Routes — 認証が必要なルートを保護する
パスレスルート(path を持たないルート)を使うパターンです。URLを変更せずに、認証チェックのロジックだけをラップします。
// hooks/useAuth.ts
interface AuthState {
isAuthenticated: boolean
isLoading: boolean
user: { role: string } | null
}
// useAuth の実装はアプリの認証方式に合わせてください(JWT、セッションなど)
export function useAuth(): AuthState { ... }
// components/ProtectedRoute.tsx
import { Navigate, Outlet, useLocation } from 'react-router'
import { useAuth } from '../hooks/useAuth'
export default function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuth()
const location = useLocation()
// 認証チェック完了を待ってからリダイレクト判断
if (isLoading) return <div>確認中...</div>
if (!isAuthenticated) {
// ログイン後に元のページへ戻れるよう、遷移先をstateに保存
return (
<Navigate
to="/login"
state={{ from: location.pathname }}
replace
/>
)
}
return <Outlet />
}
// App.tsx
export default function App() {
return (
<Routes>
{/* パブリックルート */}
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
{/* 認証必須ルート — ProtectedRoute でラップ */}
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
)
}
// pages/Login.tsx — ログイン後に元のページへリダイレクト
import { useNavigate, useLocation } from 'react-router'
import { useAuth } from '../hooks/useAuth'
export default function Login() {
const navigate = useNavigate()
const location = useLocation()
const { login } = useAuth()
// ProtectedRoute が state に保存した遷移先を取得
const from = (location.state as { from?: string })?.from ?? '/dashboard'
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await login(credentials)
navigate(from, { replace: true })
}
return <form onSubmit={handleSubmit}>...</form>
}
このパターンにより、ユーザーが /dashboard をブックマークしていた場合でも、ログイン後に正しいページへ戻ることができます。
Role-based Routes — ロールに応じたアクセス制御
Protected Routesを拡張し、ユーザーのロールに基づいてアクセスを制限します。
// components/RoleRoute.tsx
import { Navigate, Outlet } from 'react-router'
import { useAuth } from '../hooks/useAuth'
interface RoleRouteProps {
allowedRoles: string[]
}
export default function RoleRoute({ allowedRoles }: RoleRouteProps) {
const { user, isAuthenticated } = useAuth()
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
if (!user || !allowedRoles.includes(user.role)) {
return <Navigate to="/unauthorized" replace />
}
return <Outlet />
}
// App.tsx
<Routes>
<Route element={<ProtectedRoute />}>
{/* ログイン済みユーザー全員 */}
<Route path="/dashboard" element={<Dashboard />} />
{/* adminのみ */}
<Route element={<RoleRoute allowedRoles={['admin']} />}>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/admin/users" element={<ManageUsers />} />
</Route>
{/* adminとmanager */}
<Route element={<RoleRoute allowedRoles={['admin', 'manager']} />}>
<Route path="/reports" element={<Reports />} />
</Route>
</Route>
</Routes>
重要: クライアントサイドのロールチェックはUIの制御のみを目的とします。APIエンドポイント側でも必ず認可チェックを実装してください。クライアントのコードは書き換えられる可能性があります。
Lazy Loading — ルート単位でバンドルを分割する
大規模なアプリでは、すべてのコードを1つのバンドルにまとめると初回ロードが遅くなります。Lazy Loadingを使うと、各ページのコードはユーザーが実際にそのページを訪れたときに初めてダウンロードされます。
方法1: React.lazy + Suspense(宣言的モード)
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router'
// 重いページ — 必要になったタイミングでロード
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Analytics = lazy(() => import('./pages/Analytics'))
const Settings = lazy(() => import('./pages/Settings'))
// 軽いページ、または初回に必ず必要なページ — 最初からバンドルに含める
import Home from './pages/Home'
import Login from './pages/Login'
export default function App() {
return (
<Suspense fallback={<div className="page-loader">読み込み中...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
)
}
方法2: ルート定義の lazy プロパティ(Data mode)
createBrowserRouter を使う場合、ルート定義で lazy プロパティを使う方法が推奨されます。コンポーネントと loader を同時にlazy importできるため、ページに関するすべてのコードをまとめて遅延ロードできます。
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
children: [
{
path: 'dashboard',
lazy: async () => {
// コンポーネントとloaderを同時にインポート
const { default: Component, loader } = await import('./pages/Dashboard')
return { Component, loader }
},
},
{
path: 'analytics',
lazy: async () => {
const { default: Component, loader } = await import('./pages/Analytics')
return { Component, loader }
},
},
],
},
])
React.lazy との違いは、loader もまとめてlazy loadされる点です。/dashboard を訪れるまで、コンポーネントもデータ取得ロジックも一切ダウンロードされません。
よくあるミスと対処法
1. BrowserRouter を複数箇所に置いてしまう
// 問題: 2つのBrowserRouterが独立したRouterコンテキストを作成してしまう
function Content() {
return (
<BrowserRouter> {/* ← ここが問題 */}
<Routes>...</Routes>
</BrowserRouter>
)
}
// 正しい: BrowserRouter はルート(main.tsx)に1つだけ
2. レイアウトコンポーネントに <Outlet /> を置き忘れる
// 問題: Outlet がないと子ルートが表示されない
export default function DashboardLayout() {
return (
<div>
<Sidebar />
{/* Outlet がない → Settings、Profileなどがレンダリングされない */}
</div>
)
}
// 正しい:
export default function DashboardLayout() {
return (
<div>
<Sidebar />
<main>
<Outlet />
</main>
</div>
)
}
3. Data modeで useEffect によるデータ取得を重複させる
// 問題: loaderがあるのにuseEffectでも取得している(二重取得)
export default function UserDetail() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch(`/api/users/${id}`).then(r => r.json()).then(setUser)
}, [id])
if (!user) return <div>読み込み中...</div>
return <div>{user.name}</div>
}
// 正しい: loaderのデータをそのまま使う
export default function UserDetail() {
const user = useLoaderData() as User
return <div>{user.name}</div>
}
4. ログイン後のリダイレクトで replace を忘れる
// 問題: Backボタンでログインページに戻れてしまう
navigate('/dashboard')
// 正しい: historyエントリを置き換えてログインページへの遡及を防ぐ
navigate('/dashboard', { replace: true })
まとめ
| テクニック | 使うもの | タイミング |
|---|---|---|
| レンダリング前にデータ取得 |
loader + useLoaderData
|
APIからデータを必要とするすべてのルート |
| フォーム送信処理 |
action + <Form> + useActionData
|
POST / PUT / DELETE |
| グローバルローディング表示 | ルートレイアウトで useNavigation
|
Data modeを使う場合は常に |
| ルート単位のエラー処理 |
errorElement + useRouteError
|
loaderやactionが失敗する可能性がある場合 |
| 認証が必要なルートの保護 | パスレスルート + <Navigate>
|
認証機能があるアプリ全般 |
| ロールによるアクセス制御 |
RoleRoute コンポーネント |
複数のユーザー種別があるアプリ |
| バンドルの分割 |
React.lazy または ルート定義の lazy
|
ページ数が多い大規模アプリ |
React Router v7は、単純なルーティングライブラリから、ナビゲーション・データ取得・コード分割を統合的に扱えるフレームワークへと進化しています。宣言的モードとData modeの違いを理解し、用途に応じて使い分けることが、パフォーマンスと保守性の高いアプリを作る上での鍵になります。
