メインコンテンツまでスキップ

ルーティング(React Router)

この章では、React RouterによるSPAのルーティングを学びます。

この章で学ぶこと

  • React Routerの基本(Routes、Route、Link)
  • 動的ルートとパラメータ取得
  • ネストしたルートとOutlet
  • 保護されたルートとリダイレクト
備考

この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。

React Routerとは

React Routerは、Reactアプリケーションでクライアントサイドルーティングを実現するライブラリです。

SPAとクライアントサイドルーティング

従来のWebサイトでは、ページ遷移のたびにサーバーからHTMLを取得していました。SPA(Single Page Application)では、最初に1つのHTMLを読み込み、以降のページ遷移はJavaScriptで処理します。

従来のWebサイト:
/home → サーバーリクエスト → home.html
/about → サーバーリクエスト → about.html

SPA:
/home → JavaScriptでコンポーネント切り替え
/about → JavaScriptでコンポーネント切り替え(リクエストなし)

React Routerは、URLの変更を検知してReactコンポーネントを切り替えることで、この仕組みを実現しています。

セットアップ

npm install react-router
備考

本書ではReact Router v7(2024 年 11 月リリース、執筆時点 7.17.0)を使用しています。v7 ではパッケージが react-router に統合されたため、v6 以前のチュートリアルにある react-router-dom のインストールは不要です。

React Router v7 の 3 つのモード

React Router v7 は、用途に応じて3つのモードを使い分けられます。どのモードも同じ react-router パッケージから提供されるため、コードを段階的に移行できます。

モード特徴向いているケース
Declarative(宣言的)<BrowserRouter> + <Routes> + <Route> を JSX で書く。v6 以前とほぼ同じ記法小規模な SPA、学習目的、既存プロジェクトからの移行
DatacreateBrowserRouter + loader / action でデータ取得・更新をルーター側に寄せるデータ取得を伴う本格的な SPA(ほとんどの実務案件)
FrameworkVite プラグイン経由で SSR / SSG / ファイルベースルーティング / 型生成が有効にNext.js の代替として、フルスタックアプリを構築したい場合
備考

本章では Declarative モードで基本を学び、後半で Data モード(createBrowserRouter)を扱います。Framework モードは Next.js(25章)と同じ役割を担うため、SPA 主体の本書ではスコープ外とします。

警告

新規の SPA では、データ取得を useEffect で書くよりも Data モードの loader でルーター側にデータ取得を宣言するほうが推奨です。ルーター側の API で初回表示時に並列フェッチが実現でき、ウォーターフォール問題を避けられます。

基本的な使い方

ルーターの設定

// main.tsx
import { BrowserRouter } from 'react-router'

createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>
)
BrowserRouter vs HashRouter

React Routerには複数のルーター種類があります。

ルーターURL形式用途
BrowserRouter/about一般的なWebアプリ(推奨)
HashRouter/#/aboutサーバー設定ができない環境
MemoryRouterなしテスト用

BrowserRouterを使用する場合、サーバー側で全てのパスをindex.htmlに向ける設定が必要です(Viteの開発サーバーは自動で対応)。

ルートの定義

// App.tsx
import { Routes, Route } from 'react-router'

function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<Users />} />
<Route path="*" element={<NotFound />} />
</Routes>
)
}

ナビゲーション

Linkコンポーネント

import { Link } from 'react-router'

function Navigation() {
return (
<nav>
<Link to="/">ホーム</Link>
<Link to="/about">About</Link>
<Link to="/users">ユーザー</Link>
</nav>
)
}
import { NavLink } from 'react-router'

function Navigation() {
return (
<nav>
<NavLink
to="/"
className={({ isActive }) =>
isActive ? 'text-blue-500 font-bold' : 'text-gray-600'
}
>
ホーム
</NavLink>
</nav>
)
}

プログラムによるナビゲーション

import { useNavigate } from 'react-router'

function LoginForm() {
const navigate = useNavigate()

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
await login()
navigate('/dashboard') // ログイン後にダッシュボードへ
}

return <form onSubmit={handleSubmit}>...</form>
}
警告

<a href="...">タグを直接使うと、ページ全体がリロードされSPAの利点が失われます。ページ内遷移には必ずLinkまたはuseNavigateを使用してください。

試してみよう: 基本ルーティング

Home、About、Contactの3ページを作成し、ナビゲーションで切り替えられるようにしてみましょう。

ヒント
  1. RoutesRouteでパスとコンポーネントを対応付け
  2. Linkでナビゲーションを作成
  3. path="*"でNotFoundページを追加
回答と解説
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App'
import './index.css'

createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
)
// src/App.tsx
import { Routes, Route, Link } from 'react-router'

function Home() {
return <h1 className="text-2xl font-bold">ホームページ</h1>
}

function About() {
return <h1 className="text-2xl font-bold">Aboutページ</h1>
}

function Contact() {
return <h1 className="text-2xl font-bold">お問い合わせページ</h1>
}

function NotFound() {
return <h1 className="text-2xl font-bold text-red-500">404 - ページが見つかりません</h1>
}

function App() {
return (
<div className="p-8">
<nav className="mb-8 space-x-4">
<Link to="/" className="text-blue-500 hover:underline">ホーム</Link>
<Link to="/about" className="text-blue-500 hover:underline">About</Link>
<Link to="/contact" className="text-blue-500 hover:underline">お問い合わせ</Link>
</nav>

<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
)
}

export default App

BrowserRouterでアプリ全体をラップし、Routes内にRouteを定義します。Linkコンポーネントを使うことで、ページ全体をリロードせずにSPA内で遷移できます。path="*"は他のどのパスにもマッチしなかった場合のフォールバックです。

動的ルート

パラメータの取得

import { useParams } from 'react-router'

// ルート定義
<Route path="/users/:userId" element={<UserDetail />} />

// コンポーネント
function UserDetail() {
const { userId } = useParams<{ userId: string }>()

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

クエリパラメータ

import { useSearchParams } from 'react-router'

function ProductList() {
const [searchParams, setSearchParams] = useSearchParams()
const page = searchParams.get('page') || '1'
const sort = searchParams.get('sort') || 'newest'

const updatePage = (newPage: number) => {
setSearchParams({ page: String(newPage), sort })
}

const handleNextPage = () => {
updatePage(Number(page) + 1)
}

return (
<div>
<p>ページ: {page}, ソート: {sort}</p>
<button onClick={handleNextPage}>
次のページ
</button>
</div>
)
}

試してみよう: 動的ルート

/users/:userIdのルートを作成し、useParamsでユーザーIDを取得して表示してみましょう。

ヒント
  1. ルート定義で:userIdのように動的セグメントを指定
  2. useParamsでその値を取得
  3. 型引数<{ userId: string }>で型安全に
回答と解説
// src/App.tsx
import { Routes, Route, Link, useParams } from 'react-router'

function UserList() {
const users = [
{ id: 1, name: '田中太郎' },
{ id: 2, name: '山田花子' },
{ id: 3, name: '鈴木一郎' }
]

return (
<div>
<h1 className="text-2xl font-bold mb-4">ユーザー一覧</h1>
<ul className="space-y-2">
{users.map(user => (
<li key={user.id}>
<Link
to={`/users/${user.id}`}
className="text-blue-500 hover:underline"
>
{user.name}
</Link>
</li>
))}
</ul>
</div>
)
}

function UserDetail() {
const { userId } = useParams<{ userId: string }>()

// 実際のアプリではAPIからユーザー情報を取得
const userNames: Record<string, string> = {
'1': '田中太郎',
'2': '山田花子',
'3': '鈴木一郎'
}

const userName = userId ? userNames[userId] : '不明'

return (
<div>
<h1 className="text-2xl font-bold mb-4">ユーザー詳細</h1>
<p className="mb-2">ユーザーID: {userId}</p>
<p className="mb-4">名前: {userName}</p>
<Link to="/users" className="text-blue-500 hover:underline">
← 一覧に戻る
</Link>
</div>
)
}

function App() {
return (
<div className="p-8">
<nav className="mb-8">
<Link to="/" className="text-blue-500 hover:underline mr-4">ホーム</Link>
<Link to="/users" className="text-blue-500 hover:underline">ユーザー</Link>
</nav>

<Routes>
<Route path="/" element={<h1 className="text-2xl font-bold">ホーム</h1>} />
<Route path="/users" element={<UserList />} />
<Route path="/users/:userId" element={<UserDetail />} />
</Routes>
</div>
)
}

export default App

:userIdのようにコロンで始まるセグメントは動的パラメータです。useParamsフックで取得した値は常に文字列型なので、数値として使用する場合はNumber(userId)で変換します。

ネストしたルート

// App.tsx
function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="users" element={<UsersLayout />}>
<Route index element={<UserList />} />
<Route path=":userId" element={<UserDetail />} />
</Route>
</Route>
</Routes>
)
}

// Layout.tsx
import { Outlet } from 'react-router'

function Layout() {
return (
<div>
<Header />
<main>
<Outlet /> {/* 子ルートがここにレンダリングされる */}
</main>
<Footer />
</div>
)
}

試してみよう: ネストしたルート

共通レイアウト(Header + Footer)を持つLayoutコンポーネントを作成し、Outletで子ルートをレンダリングしてみましょう。

ヒント
  1. 親ルートで<Layout />elementに設定
  2. 子ルートのコンポーネントは<Outlet />の位置にレンダリング
  3. NavLinkendプロパティで完全一致のときだけアクティブに
回答と解説
// src/components/Layout.tsx
import { Outlet, NavLink } from 'react-router'

export function Layout() {
const linkClass = ({ isActive }: { isActive: boolean }) =>
`px-4 py-2 rounded ${isActive ? 'bg-blue-500 text-white' : 'text-gray-600 hover:bg-gray-100'}`

return (
<div className="min-h-screen flex flex-col">
<header className="bg-white shadow">
<nav className="max-w-4xl mx-auto px-4 py-4 flex gap-2">
<NavLink to="/" className={linkClass} end>
ホーム
</NavLink>
<NavLink to="/about" className={linkClass}>
About
</NavLink>
<NavLink to="/contact" className={linkClass}>
お問い合わせ
</NavLink>
</nav>
</header>

<main className="flex-1 max-w-4xl mx-auto px-4 py-8 w-full">
<Outlet />
</main>

<footer className="bg-gray-100 py-4 text-center text-gray-600">
© {new Date().getFullYear()} My App
</footer>
</div>
)
}
// src/pages/Home.tsx
export function Home() {
return (
<div>
<h1 className="text-3xl font-bold mb-4">ホームページへようこそ</h1>
<p className="text-gray-600">
これはReact Routerを使用したSPAのサンプルです。
</p>
</div>
)
}

// src/pages/About.tsx
export function About() {
return (
<div>
<h1 className="text-3xl font-bold mb-4">About</h1>
<p className="text-gray-600">
このアプリケーションについての説明ページです。
</p>
</div>
)
}

// src/pages/Contact.tsx
export function Contact() {
return (
<div>
<h1 className="text-3xl font-bold mb-4">お問い合わせ</h1>
<p className="text-gray-600">
お問い合わせはこちらからお願いします。
</p>
</div>
)
}
// src/App.tsx
import { Routes, Route } from 'react-router'
import { Layout } from './components/Layout'
import { Home } from './pages/Home'
import { About } from './pages/About'
import { Contact } from './pages/Contact'

function NotFound() {
return (
<div>
<h1 className="text-3xl font-bold text-red-500 mb-4">404</h1>
<p>ページが見つかりませんでした。</p>
</div>
)
}

function App() {
return (
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
)
}

export default App

ネストしたルートでは、親ルートの<Outlet />の位置に子ルートのコンポーネントがレンダリングされます。NavLinkendプロパティは、完全一致のときだけアクティブになるようにするためのものです(//aboutなどでもアクティブにならないように)。共通レイアウトを1箇所にまとめることで、コードの重複を避けられます。

保護されたルート

import { Navigate, useLocation } from 'react-router'

type ProtectedRouteProps = {
children: React.ReactNode
}

function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuth()
const location = useLocation()

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

return <>{children}</>
}

// 使用例
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>

ログイン後のリダイレクト

function LoginPage() {
const navigate = useNavigate()
const location = useLocation()

const from = location.state?.from?.pathname || '/'

const handleLogin = async () => {
await login()
navigate(from, { replace: true }) // 元のページに戻る
}

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

データローダー(推奨パターン)

Data モード(createBrowserRouter)では、ルートにローダーを設定できます(v6.4 で導入され、v7 では Data モードの中核機能になっています)。

備考

データローダーは、コンポーネントがレンダリングされる前にデータを取得するパターンです。TanStack Query(20章)と組み合わせての使用も可能です。

import { createBrowserRouter, useLoaderData } from 'react-router'
import type { LoaderFunctionArgs } from 'react-router'
// RouterProvider だけは 'react-router/dom' から import する(react-dom 連携版)
import { RouterProvider } from 'react-router/dom'

// ローダー関数
async function userLoader({ params }: LoaderFunctionArgs) {
const response = await fetch(`/api/users/${params.userId}`)
if (!response.ok) throw new Error('User not found')
return response.json()
}

// ルーター設定
const router = createBrowserRouter([
{
path: '/users/:userId',
element: <UserDetail />,
loader: userLoader,
errorElement: <ErrorPage />
}
])

// コンポーネント
function UserDetail() {
const user = useLoaderData() as User

return <h1>{user.name}</h1>
}

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

ローディング状態

備考

useNavigation は Data モード(createBrowserRouter + RouterProvider)専用のフックです。前半の <BrowserRouter> 構成では使えません。

import { useNavigation } from 'react-router'

function App() {
const navigation = useNavigation()

return (
<div>
{navigation.state === 'loading' && <LoadingBar />}
<Outlet />
</div>
)
}

エラーハンドリング

import { useRouteError, isRouteErrorResponse } from 'react-router'

function ErrorPage() {
const error = useRouteError()

if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status}</h1>
<p>{error.statusText}</p>
</div>
)
}

return (
<div>
<h1>エラーが発生しました</h1>
<p>{error instanceof Error ? error.message : '不明なエラー'}</p>
</div>
)
}

ページタイトルの管理(React 19 の Document Metadata)

SPA では URL が変わってもブラウザタブのタイトルが変わらないため、ルートごとに document.title を更新する必要があります。React 19 では、JSX で <title> / <meta> / <link> を書くだけで自動的に <head> へ巻き上げられるため、これが簡潔になりました。

// ページコンポーネントの中で直接書くだけでOK
function AboutPage() {
return (
<>
<title>会社について | MyApp</title>
<meta name="description" content="MyAppの会社情報ページです" />
<h1>会社について</h1>
<p>...</p>
</>
)
}

React 19 より前は react-helmet-async などのライブラリを使っていましたが、新規の SPA では React 19 内蔵の機能だけで十分です。

備考

SSR(Next.js)での注意: Next.js には専用の metadata API(25章で解説)があり、React 19 の Document Metadata と機能が重複します。そのため Next.js 側では metadata / generateMetadata を使うのが基本です。Next.js のドキュメントでも「ページ単位のメタデータは metadata export を使う」と案内されています。

まとめ

  • React Routerはクライアントサイドルーティングのデファクトスタンダード
  • RoutesRouteでルート定義
  • Link/NavLinkでナビゲーション
  • useParamsでURLパラメータ取得
  • useSearchParamsでクエリパラメータ管理
  • Outletでネストしたルートをレンダリング
  • 保護されたルートで認証制御
  • ローダーでデータ取得を宣言的に
  • React 19 では <title> / <meta> を JSX で直接書くだけで <head> に反映される

次の章では、パフォーマンス最適化について学びます。

次に読む

発展的トピック へ進みます。パフォーマンス最適化、デバッグ、クラスコンポーネント、Next.js 入門など発展的トピックを扱います。