10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Router v7 応用編 — 第2回:実践で使う上級テクニック

10
Posted at

image.png

第1回では、React Router v7の基本的なコンセプトを解説しました。この記事では、実際のプロダクト開発で必要になる応用テクニックを扱います。loader/action を使ったData mode、Protected Routes、Lazy Loadingがメインテーマです。

前提: 第1回の内容(BrowserRouterRoutes/RouteLink/NavLinkuseParamsuseNavigate)を理解していること


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 — フォームの送信処理

actionloader と対になる概念です。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の違いを理解し、用途に応じて使い分けることが、パフォーマンスと保守性の高いアプリを作る上での鍵になります。

10
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?