- React
React Routerの使い方|v7のルーティング設定とネスト・404対応
React Routerとは、URLと画面(コンポーネント)を結びつけ、ページを読み込み直さずに画面を切り替えるためのReact用ライブラリです。 v7からは「宣言モード」「データモード」「フレームワークモード」の3つの使い方があり、どれを選ぶかで書き方が変わります。
この記事では、一覧・詳細・ネストしたレイアウト・404を持つ同じ画面を、3つのモードそれぞれの全文コードで作ります。コードはすべて手元でビルドし、Chromium(Playwright 1.56.0)で操作した結果を載せています。フックの用途早見表、v6からの差分、リロードで404になるときのサーバー設定も1ページで確認できます。
React Routerの早見表
| やりたいこと | 書き方 | 使えるモード |
|---|---|---|
| インストールする | npm i react-router |
すべて |
| URLと画面を結ぶ | Route path="items" element={...} |
宣言 |
| URLと画面を結ぶ(オブジェクト) | createBrowserRouter([{ path, Component }]) |
データ |
| URLと画面を結ぶ(ファイル) | app/routes.ts に route("items", "./routes/x.tsx") |
フレームワーク |
| リンクを置く | Link to="/items" |
すべて |
| 今いるページのリンクを目立たせる | NavLink(active クラスと aria-current="page" が付く) |
すべて |
| 共通レイアウトの中に子を表示する | 親に Outlet を置く |
すべて |
URLの :itemId を読む |
const { itemId } = useParams() |
すべて |
?q= を読む・書き換える |
const [searchParams, setSearchParams] = useSearchParams() |
すべて |
| ボタンで移動する・戻る | const navigate = useNavigate() → navigate("/items") / navigate(-1) |
すべて |
| 今のパスを知る | useLocation().pathname |
すべて |
| 一致しないURLに404画面を出す | path="*" のルートを置く |
すべて |
| 画面を描く前にデータを用意する | loader と useLoaderData() |
データ・フレームワーク |
| HTTPステータスも404にする | loader で data(null, { status: 404 }) |
フレームワーク(サーバーで描画する場合) |
| リロードで404になる | サーバーで全URLを index.html に向ける |
宣言・データ |
確認した環境とバージョン
2026年10月3日時点で、npmの最新版は React Router 8.4.0 です(npm view react-router dist-tags で確認。v7系の最新は 7.18.4)。この記事のコードは次の環境で動かしました。
| 項目 | バージョン |
|---|---|
| react-router / @react-router/dev / @react-router/serve | 8.4.0 |
| react / react-dom | 19.3.0 |
| Vite / @vitejs/plugin-react | 8.3.2 / 6.1.1 |
| TypeScript | 7.0.2 |
| Node.js | 22.22.2 |
| ブラウザ | Chromium(Playwright 1.56.0) |
宣言モードとデータモードのコードは、react-router 7.18.4 に入れ替えても同じソースのまま型チェックとビルドが通りました(フレームワークモードは 8.4.0 でのみ確認)。v7で書き始めた方も、この記事のコードをそのまま使えます。v8では最小要件が Node.js 22.22.0 以上・React 19.2.7 以上になり、react-router-dom パッケージがなくなった点が主な違いです(パッケージ同梱の変更履歴とアップグレード手順書で確認)。
3つのモードの違いと選び方
3つのモードは別々のライブラリではなく、同じ react-router パッケージの使い方の段階です。下に行くほど機能が増え、そのぶん決まりごとも増えます。
| 比べる点 | 宣言モード | データモード | フレームワークモード |
|---|---|---|---|
| 入口のAPI | BrowserRouter |
createBrowserRouter と RouterProvider |
Viteプラグイン reactRouter() と app/routes.ts |
| ルートの書き場所 | JSXの Routes / Route |
オブジェクトの配列 | routes.ts(1ルート1ファイル) |
| データの読み込み | 自分で書く(useEffectなど) | loader / action |
loader / action(サーバーでも動く) |
| ルートのエラー処理 | 自分で書く | ErrorBoundary と useRouteError |
ErrorBoundary(ルートごとに書ける) |
| 型の支援 | 手書き | useLoaderData<typeof loader>() |
Route.ComponentProps などを自動生成 |
| コード分割 | 自分で lazy を書く |
lazy プロパティ |
ルートごとに自動 |
| 描画する場所 | ブラウザのみ | ブラウザのみ(サーバー描画は自作) | サーバー描画・SPA・事前生成から選ぶ |
| 追加パッケージ | なし | なし | @react-router/dev、@react-router/node など |
| 手元のビルド後JS(今回の例) | 260.20 kB(1ファイル) | 315.55 kB(1ファイル) | 13ファイルに分割(最大 214.82 kB) |
選び方の目安は次のとおりです(公式ドキュメントの「Picking a Mode」の説明に沿っています)。
| こんなとき | 選ぶモード |
|---|---|
| 新しく作る。データ取得や型、コード分割まで任せたい | フレームワークモード |
| 既存のViteアプリに足す。バンドルやサーバーは自分で決めたいが、loaderやエラー処理は使いたい | データモード |
| なるべく小さく始めたい。データ取得は別のライブラリで行っている。v6以前の書き方から移る | 宣言モード |
宣言モードからデータモードへは、ルートの定義をJSXからオブジェクトに移すだけで上がれます。画面のコンポーネントやフックはほぼそのまま使えるので、迷ったら宣言モードで始めて、必要になったら移るという進め方もできます。
インストールと共通の準備
宣言モードとデータモードは、Viteで作ったReactアプリに react-router を足すだけです。
npm create vite@latest bihin -- --template react-ts
cd bihin
npm i react-router
3つのモードで共通して使うデータを src/items.ts(フレームワークモードでは app/items.ts)に置きます。備品の一覧という想定です。
export type Item = { id: number; name: string; stock: number };
export const items: Item[] = [
{ id: 1, name: "ノートPC", stock: 12 },
{ id: 2, name: "モニター", stock: 5 },
{ id: 3, name: "キーボード", stock: 0 },
];
export function findItem(id: string | undefined): Item | undefined {
return items.find((item) => String(item.id) === id);
}
作る画面は次の4つです。URLの階層と、レイアウトの入れ子がそのまま対応します。
| URL | 表示するもの | レイアウト |
|---|---|---|
/ |
ホーム | 全体レイアウト |
/items |
備品一覧(?q= で絞り込み) |
全体レイアウト > 備品レイアウト |
/items/:itemId |
備品の詳細 | 全体レイアウト > 備品レイアウト |
| それ以外 | 404画面 | 全体レイアウト |
flowchart TD
L["全体レイアウト(ヘッダーとナビ)"] --> H["/ ホーム"]
L --> I["備品レイアウト(見出し)"]
I --> IL["/items 一覧"]
I --> ID["/items/:itemId 詳細"]
L --> N["* 404画面"]
宣言モードで作る(BrowserRouterとRoutes)
宣言モードは、JSXでルートを並べる、いちばん手軽な書き方です。入口で BrowserRouter で包み、Routes の中に Route を入れ子にします。
src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
src/App.tsx
import {
Link,
NavLink,
Outlet,
Route,
Routes,
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router";
import { findItem, items } from "./items";
// 全ページ共通のレイアウト(ヘッダー+子ルートの表示場所)
function Layout() {
const location = useLocation();
return (
<div>
<header>
<nav>
<NavLink to="/" end>ホーム</NavLink>{" | "}
<NavLink to="/items">備品一覧</NavLink>
</nav>
<p id="path">現在のパス: {location.pathname}{location.search}</p>
</header>
<main>
<Outlet />
</main>
</div>
);
}
function Home() {
return <h1>ホーム</h1>;
}
// /items 以下だけに効く、ネストしたレイアウト
function ItemsLayout() {
return (
<section>
<h1>備品</h1>
<Outlet />
</section>
);
}
function ItemList() {
const [searchParams, setSearchParams] = useSearchParams();
const q = searchParams.get("q") ?? "";
const shown = items.filter((item) => item.name.includes(q));
return (
<>
<input
aria-label="検索"
value={q}
onChange={(e) =>
setSearchParams(e.target.value ? { q: e.target.value } : {})
}
/>
<ul>
{shown.map((item) => (
<li key={item.id}>
<Link to={`/items/${item.id}`}>{item.name}</Link>
</li>
))}
</ul>
</>
);
}
function ItemDetail() {
const { itemId } = useParams();
const navigate = useNavigate();
const item = findItem(itemId);
if (!item) return <NotFound />;
return (
<article>
<h2>{item.name}</h2>
<p>在庫: {item.stock}</p>
<button onClick={() => navigate(-1)}>戻る</button>
<button onClick={() => navigate("/items", { replace: true })}>
一覧へ
</button>
</article>
);
}
function NotFound() {
return (
<div>
<h2>404 ページが見つかりません</h2>
<Link to="/">ホームへ戻る</Link>
</div>
);
}
export default function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route index element={<Home />} />
<Route path="items" element={<ItemsLayout />}>
<Route index element={<ItemList />} />
<Route path=":itemId" element={<ItemDetail />} />
</Route>
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}
読むときのポイントは3つです。
pathを持たないRoute element={...}はレイアウト専用のルートです。URLには影響せず、子をOutletの位置に表示します。indexは「親と同じURLのときに表示する子」です。/itemsで一覧が出るのはindexのおかげです。- 子の
pathは先頭にスラッシュを付けない相対指定にします。itemsの中の:itemIdは/items/:itemIdになります。
データモードで作る(createBrowserRouter)
データモードは、ルートをオブジェクトの配列で定義し、loader でデータを先に用意する書き方です。存在しないIDのときは loader の中で404を投げ、ErrorBoundary に指定したコンポーネントで受け取ります。
src/main.tsx(RouterProvider は react-router/dom から読み込みます)
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router/dom";
import { router } from "./router";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
src/router.tsx
import {
createBrowserRouter,
data,
isRouteErrorResponse,
Link,
NavLink,
Outlet,
useLoaderData,
useLocation,
useNavigate,
useRouteError,
useSearchParams,
type LoaderFunctionArgs,
} from "react-router";
import { findItem, items, type Item } from "./items";
function Layout() {
const location = useLocation();
return (
<div>
<header>
<nav>
<NavLink to="/" end>ホーム</NavLink>{" | "}
<NavLink to="/items">備品一覧</NavLink>
</nav>
<p id="path">現在のパス: {location.pathname}{location.search}</p>
</header>
<main>
<Outlet />
</main>
</div>
);
}
function Home() {
return <h1>ホーム</h1>;
}
function ItemsLayout() {
return (
<section>
<h1>備品</h1>
<Outlet />
</section>
);
}
// loader:画面を描く前にデータを用意する関数
function listLoader({ request }: LoaderFunctionArgs) {
const q = new URL(request.url).searchParams.get("q") ?? "";
return items.filter((item) => item.name.includes(q));
}
function ItemList() {
const shown = useLoaderData<typeof listLoader>();
const [searchParams, setSearchParams] = useSearchParams();
return (
<>
<input
aria-label="検索"
value={searchParams.get("q") ?? ""}
onChange={(e) =>
setSearchParams(e.target.value ? { q: e.target.value } : {})
}
/>
<ul>
{shown.map((item) => (
<li key={item.id}>
<Link to={`/items/${item.id}`}>{item.name}</Link>
</li>
))}
</ul>
</>
);
}
function detailLoader({ params }: LoaderFunctionArgs): Item {
const item = findItem(params.itemId);
if (!item) {
// 見つからないときは 404 を投げる → ErrorBoundary が受け取る
throw data("備品が見つかりません", { status: 404 });
}
return item;
}
function ItemDetail() {
const item = useLoaderData<typeof detailLoader>();
const navigate = useNavigate();
return (
<article>
<h2>{item.name}</h2>
<p>在庫: {item.stock}</p>
<button onClick={() => navigate(-1)}>戻る</button>
</article>
);
}
// ルートで起きたエラー(404 を含む)をまとめて表示する
function NotFound() {
return (
<div>
<h2>404 ページが見つかりません</h2>
<Link to="/">ホームへ戻る</Link>
</div>
);
}
function RootError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h2>{error.status} {error.status === 404 ? "ページが見つかりません" : "エラー"}</h2>
<p id="detail">{String(error.data)}</p>
<Link to="/">ホームへ戻る</Link>
</div>
);
}
return <h2>予期しないエラーが起きました</h2>;
}
export const router = createBrowserRouter([
{
path: "/",
Component: Layout,
children: [
{
// パスを持たないルート:子のエラーをレイアウトの内側で受ける
ErrorBoundary: RootError,
children: [
{ index: true, Component: Home },
{
path: "items",
Component: ItemsLayout,
children: [
{ index: true, loader: listLoader, Component: ItemList },
{ path: ":itemId", loader: detailLoader, Component: ItemDetail },
],
},
// どのルートにも一致しない URL はここで受ける
{ path: "*", Component: NotFound },
],
},
],
},
]);
宣言モードとの違いは次のとおりです。
useParamsで読んでいたIDを、loaderの引数paramsで受け取るようになりました。コンポーネントは「データがある前提」で書けるので、if (!item)の分岐が消えます。data("...", { status: 404 })を throw すると、いちばん近いErrorBoundaryが表示されます。isRouteErrorResponse(error)で判定するとerror.statusとerror.dataを読めます。ErrorBoundaryはそのルートのコンポーネントの代わりに表示されます。上のコードでは、パスを持たないルートにErrorBoundaryを置いて、全体レイアウト(ヘッダー)を残したままエラー画面を出しています。
フレームワークモードで作る(routes.tsとルートモジュール)
フレームワークモードは、Viteプラグインがルートごとのファイルをまとめ、型の生成・コード分割・サーバー描画まで面倒を見る書き方です。新しく始めるときは npx create-react-router@latest でひな形を作れます(この記事では、ひな形を使わず下のファイルを手で置いて確認しました。ひな形の生成は未実行です)。
npm i react react-dom react-router @react-router/node @react-router/serve isbot
npm i -D @react-router/dev vite typescript @types/react @types/react-dom @types/node
package.json(scriptsの部分)
{
"type": "module",
"scripts": {
"dev": "react-router dev",
"build": "react-router build",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc --noEmit"
}
}
vite.config.ts
import { reactRouter } from "@react-router/dev/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [reactRouter()],
});
react-router.config.ts
import type { Config } from "@react-router/dev/config";
export default {
ssr: true, // false にすると SPA モード(静的ファイルだけで配信)
} satisfies Config;
app/routes.ts(ルートの一覧。入れ子がそのままレイアウトの入れ子になります)
import {
type RouteConfig,
index,
layout,
route,
} from "@react-router/dev/routes";
export default [
layout("./routes/site-layout.tsx", [
index("./routes/home.tsx"),
route("items", "./routes/items-layout.tsx", [
index("./routes/item-list.tsx"),
route(":itemId", "./routes/item-detail.tsx"),
]),
route("*", "./routes/catchall.tsx"),
]),
] satisfies RouteConfig;
app/root.tsx
import {
isRouteErrorResponse,
Link,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
// HTML 全体の骨組み。フレームワークモードでは index.html の代わりにここへ書く
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<head>
<meta charSet="utf-8" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
// どのルートにも ErrorBoundary が無いとき、最後にここで受け取る
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return (
<main>
<h2>{error.status} {error.status === 404 ? "ページが見つかりません" : "エラー"}</h2>
<p id="detail">{String(error.data)}</p>
<Link to="/">ホームへ戻る</Link>
</main>
);
}
return <h2>予期しないエラーが起きました</h2>;
}
app/routes/site-layout.tsx
import { NavLink, Outlet, useLocation } from "react-router";
export default function SiteLayout() {
const location = useLocation();
return (
<div>
<header>
<nav>
<NavLink to="/" end>ホーム</NavLink>{" | "}
<NavLink to="/items">備品一覧</NavLink>
</nav>
<p id="path">現在のパス: {location.pathname}{location.search}</p>
</header>
<main>
<Outlet />
</main>
</div>
);
}
app/routes/home.tsx
import type { Route } from "./+types/home";
export function meta({}: Route.MetaArgs) {
return [{ title: "ホーム | 備品管理" }];
}
export default function Home() {
return <h1>ホーム</h1>;
}
app/routes/items-layout.tsx
import { Outlet } from "react-router";
export default function ItemsLayout() {
return (
<section>
<h1>備品</h1>
<Outlet />
</section>
);
}
app/routes/item-list.tsx
import { Link, useSearchParams } from "react-router";
import { items } from "../items";
import type { Route } from "./+types/item-list";
// loader はサーバー側で動く。戻り値が loaderData として届く
export function loader({ request }: Route.LoaderArgs) {
const q = new URL(request.url).searchParams.get("q") ?? "";
return items.filter((item) => item.name.includes(q));
}
export default function ItemList({ loaderData }: Route.ComponentProps) {
const [searchParams, setSearchParams] = useSearchParams();
return (
<>
<input
aria-label="検索"
value={searchParams.get("q") ?? ""}
onChange={(e) =>
setSearchParams(e.target.value ? { q: e.target.value } : {})
}
/>
<ul>
{loaderData.map((item) => (
<li key={item.id}>
<Link to={`/items/${item.id}`}>{item.name}</Link>
</li>
))}
</ul>
</>
);
}
app/routes/item-detail.tsx
import { data, useNavigate } from "react-router";
import { findItem } from "../items";
import type { Route } from "./+types/item-detail";
export function loader({ params }: Route.LoaderArgs) {
const item = findItem(params.itemId); // params.itemId は string 型と推論される
if (!item) {
throw data("備品が見つかりません", { status: 404 });
}
return item;
}
export function meta({ loaderData }: Route.MetaArgs) {
return [{ title: `${loaderData?.name ?? "備品"} | 備品管理` }];
}
export default function ItemDetail({ loaderData }: Route.ComponentProps) {
const navigate = useNavigate();
return (
<article>
<h2>{loaderData.name}</h2>
<p>在庫: {loaderData.stock}</p>
<button onClick={() => navigate(-1)}>戻る</button>
</article>
);
}
app/routes/catchall.tsx
import { data, Link } from "react-router";
// どのルートにも一致しない URL をここで受ける。
// data() でステータスを 404 にしつつ、画面はレイアウトの内側に描く
export function loader() {
return data(null, { status: 404 });
}
export default function CatchAll() {
return (
<div>
<h2>404 ページが見つかりません</h2>
<Link to="/">ホームへ戻る</Link>
</div>
);
}
./+types/ファイル名 は react-router typegen が自動で作る型です。Route.LoaderArgs を使うと params.itemId が string と推論され、Route.ComponentProps の loaderData には loader の戻り値の型が入ります。tsconfig.json には、生成された型を読めるよう次の2行を入れます。
{
"include": ["app", ".react-router/types/**/*"],
"compilerOptions": {
"rootDirs": [".", "./.react-router/types"]
}
}
react-router build の出力(抜粋)です。ルートごとにファイルが分かれていることがわかります。
build/client/assets/catchall-Bz9SZdSi.js 0.09 kB │ gzip: 0.11 kB
build/client/assets/items-layout-B51emRci.js 0.23 kB │ gzip: 0.19 kB
build/client/assets/home-0l71fT07.js 0.25 kB │ gzip: 0.22 kB
build/client/assets/item-detail-CwPsuQ8h.js 0.44 kB │ gzip: 0.33 kB
build/client/assets/item-list-DUKqEplp.js 0.48 kB │ gzip: 0.33 kB
build/client/assets/site-layout-D-yk88p0.js 0.53 kB │ gzip: 0.35 kB
build/client/assets/root-D3G_pQFx.js 0.91 kB │ gzip: 0.53 kB
build/client/assets/entry.client-VC9gT9z-.js 214.82 kB │ gzip: 67.19 kB
build/server/index.js 13.88 kB │ gzip: 3.27 kB
(上は catchall.tsx を書き換える前のビルドの出力です。ファイル名のハッシュはビルドのたびに変わります。)
同じ操作を3つのモードで動かした結果
ビルドしたものをサーバーで配信し、Chromiumで同じ操作をしました。宣言・データモードは vite preview、フレームワークモードは react-router-serve で配信しています。
| 操作 | 宣言モード | データモード | フレームワークモード |
|---|---|---|---|
/ を開く |
h1=ホーム、現在のパス: / | 同じ | 同じ |
| 「備品一覧」をクリック | 現在のパス: /items、3件表示 | 同じ | 同じ |
| そのときの NavLink | class="active"、aria-current="page" |
同じ | 同じ |
| 検索欄に「モニ」と入力 | URLが /items?q=モニ、1件表示 |
同じ | 同じ |
| 「モニター」をクリック | /items/2、在庫: 5 | 同じ | 同じ |
「戻る」(navigate(-1)) |
/items?q=モニ に戻る | 同じ | 同じ |
/items/99 を開く |
404画面(全体・備品レイアウトあり) | 404画面(全体レイアウトあり) | 404画面(root.tsx の ErrorBoundary。レイアウトなし) |
/no/such/page を開く |
404画面、HTTP 200 | 404画面、HTTP 200 | 404画面(全体レイアウトあり)、HTTP 404 |
/items/2 でリロード |
HTTP 200(配信側の設定による) | HTTP 200(同左) | HTTP 200 |
画面の動きは3モードとも同じで、違いが出るのは「存在しないURLのHTTPステータス」と「エラー画面がどのレイアウトの中に出るか」です。宣言・データモードはブラウザの中だけで404画面を描くため、サーバーの応答は200のままです。検索エンジンにも404と伝えたい場合は、サーバーで描画するフレームワークモードが向いています。
フックとコンポーネントの用途早見表
| 名前 | 何をするか | 書き方の例 | 補足 |
|---|---|---|---|
useNavigate |
コードから移動する | navigate("/items")、navigate(-1)、navigate("/items", { replace: true }) |
replace: true は履歴を増やさずに置き換える。クリックで移動するだけなら Link を使う |
useParams |
パスの :名前 の値を読む |
const { itemId } = useParams() |
値は文字列(または undefined)。数値にしたいときは自分で変換する |
useSearchParams |
?q=... を読む・書き換える |
searchParams.get("q")、setSearchParams({ q: "モニ" }) |
書き換えるとURLが変わり、画面も描き直される |
useLocation |
今のURL情報を読む | location.pathname、location.search |
アクセス解析やパンくずの表示に使う |
Outlet |
子ルートを表示する場所 | 親のコンポーネントの中に置く | 置き忘れると子が表示されない |
Link |
読み込み直しなしで移動するリンク | Link to="/items/2" |
画面には a 要素として出力される |
NavLink |
今いるページを判定できるリンク | NavLink to="/" end |
一致すると active クラスが付く。end は完全一致のときだけにする指定 |
useLoaderData |
loader の戻り値を読む |
useLoaderData<typeof loader>() |
データ・フレームワークモード用 |
useRouteError |
ルートで起きたエラーを読む | isRouteErrorResponse(error) と組み合わせる |
データ・フレームワークモード用 |
フックは BrowserRouter(または RouterProvider)の内側のコンポーネントでだけ使えます。入口の main.tsx で全体を包んでおくと、どの画面からでも使えます(外側で呼んだときの挙動は今回は未実行です)。
404対応の書き方とつまずきやすい点
どのルートにも一致しないURLは、path="*"(スプラットルート)で受けます。これは3モード共通です。全体レイアウトの子として置くと、ヘッダーを残したまま404画面を出せます。
「URLの形は合っているが、データがない」場合(/items/99)は、モードごとに書き方が変わります。
| 場面 | 宣言モード | データ・フレームワークモード |
|---|---|---|
| 一致するルートがない | Route path="*" |
{ path: "*", Component: NotFound } / route("*", ...) |
| IDに対応するデータがない | コンポーネントの中で分岐して404画面を返す | loader で throw data("...", { status: 404 }) |
データモードで path: "*" を置き忘れたときの表示も確認しました。ErrorBoundary を子のルートだけに置いた状態で /no/such/page を開くと、React Routerの既定のエラー画面が出ます。
Unexpected Application Error!
404 Not Found
一致するルートがないときのエラーは、いちばん外側のルートで起きた扱いになるためです。path: "*" のルートを足す(または、いちばん外側のルートにも ErrorBoundary を置く)と、用意した404画面に変わりました。
フレームワークモードの catchall.tsx では、loader から data(null, { status: 404 }) を return しています。throw ではなく return にすると、通常のコンポーネントが全体レイアウトの中に描かれ、HTTPステータスだけが404になります。react-router-serve のログでも確認できました。
GET /items/2 200 - - 8.432 ms
GET /items/99 404 - - 3.829 ms
GET /nope 404 - - 3.748 ms
リロードで404になるときのサーバー設定
宣言モード・データモード(とフレームワークモードのSPA設定)で作ったアプリは、サーバーから見ると index.html 1枚と JS ファイルだけです。 リンクのクリックで /items/2 に移動するのはブラウザの中の処理ですが、そこでリロードするとブラウザはサーバーに /items/2 を取りに行きます。サーバーにそのファイルはないので、設定をしていないと404が返ります。
ビルドした dist を、設定の違うサーバーで配信して確かめました。
| 配信方法 | /items/2 に直接アクセス |
結果 |
|---|---|---|
python3 -m http.server(ファイルを返すだけ) |
404 File not found | 画面が出ない |
Nginx 1.24.0(root のみ) |
404 | 画面が出ない |
Nginx 1.24.0(try_files あり) |
200 | 詳細画面が出る |
Vite の開発サーバー(vite) |
200 | 詳細画面が出る |
vite preview |
200 | 詳細画面が出る |
対処は共通で、「ファイルが見つからないURLは index.html を返す」設定をサーバーに入れます。
Viteの場合
Viteの開発サーバーと vite preview は、既定でこの振り分けをしてくれます(上の表のとおり、設定なしで200でした)。開発中は動いていたのにデプロイ後だけ404になるのは、配信先のサーバーにこの設定がないためです。なお vite preview はビルド結果の確認用なので、公開用のサーバーには下のNginxやホスティングの設定を使います。
Nginxの場合
server {
listen 80;
root /var/www/bihin/dist;
# JS や CSS は、無ければそのまま 404 を返す
location /assets/ {
try_files $uri =404;
}
# それ以外は、ファイルが無ければ index.html を返す
location / {
try_files $uri $uri/ /index.html;
}
}
location /assets/ を分けているのには理由があります。location / の設定だけだと、存在しない JS ファイルへのリクエストにも index.html(200、text/html)が返りました。 /assets/ を分けたあとは 404 が返ります。
# location / だけのとき
/assets/none.js -> 200 text/html
# location /assets/ を足したあと
/items/2 -> 200 text/html
/assets/none.js -> 404 text/html
JSを待っているブラウザにHTMLが届くと原因がわかりにくいエラーになるので、アセットのフォルダは分けておくと調べやすくなります(ポート番号と root のパスは確認用の値から置き換えて載せています)。
Vercelの場合
Vercelでは、プロジェクトの直下に vercel.json を置いて書き換えルールを指定します。次の内容は、Vercelの公式ドキュメント「Vite on Vercel」に載っている設定です(今回ページを開いて確認。Vercelへのデプロイ自体は未実行です)。
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}
そのほか、_redirects ファイルに /* /index.html 200 と書く形式に対応した静的ホスティングもあります(React Router同梱のドキュメントの例。未実行)。
フレームワークモードでサーバー描画(ssr: true)を使う場合は、サーバーがすべてのURLを受けて描画するので、この設定は不要です。
v6から移るときの差分
| 項目 | v6 | v7 | v8(8.4.0で確認) |
|---|---|---|---|
| インストールするパッケージ | react-router-dom |
react-router(react-router-dom も互換用に残る) |
react-router のみ |
Link やフックの import 元 |
react-router-dom |
react-router |
react-router |
RouterProvider の import 元 |
react-router-dom |
react-router/dom |
react-router/dom |
| フレームワークモード | なし(Remixが担当) | あり | あり |
| React の最小バージョン | 16.8 | 18 | 19.2.7 |
| Node.js の最小バージョン | 14 | 20 | 22.22.0 |
(v6・v7 の最小バージョンは一般に知られた仕様として記載。v8 の値はパッケージ同梱の変更履歴で確認しました。)
いちばん多い修正は import の書き換えです。
// 変更前(v6)
import { BrowserRouter, Link, useNavigate } from "react-router-dom";
import { RouterProvider } from "react-router-dom";
// 変更後(v7 以降)
import { BrowserRouter, Link, useNavigate } from "react-router";
import { RouterProvider } from "react-router/dom";
v8 を入れた状態で react-router-dom から import したままだと、次のエラーで止まります。
# tsc --noEmit
dom/src/main.tsx(3,31): error TS2307: Cannot find module 'react-router-dom' or its corresponding type declarations.
# vite build
Error: [vite]: Rolldown failed to resolve import "react-router-dom" from ".../dom/src/main.tsx".
npm uninstall react-router-dom のあと、import 元を一括で置き換えると解消します。Routes・Route・Link・useNavigate・useParams などの使い方自体は v6 から変わっていないので、宣言モードの画面コードはそのまま使えます。
よくある質問
Q. 3つのモードのうち、どれから始めるのがよいですか? A. 新しく作るならフレームワークモード、既存のViteアプリに足すなら宣言モードかデータモードが始めやすいです。宣言モードの画面コードはデータモードでもほぼそのまま使えるので、小さく始めてあとから移ることもできます。
Q. useNavigate と Link はどう使い分けますか?
A. 利用者がクリックして移動するだけなら Link を使います。画面には a 要素として出力されるので、新しいタブで開く操作やキーボード操作にもそのまま対応できます。useNavigate は、保存が終わったあとに一覧へ移る、前の画面に戻るなど、処理の結果として移動するときに使います。
Q. useParams の値が数値として扱えません。
A. URLから取り出した値はすべて文字列です。Number(itemId) で変換するか、この記事の findItem のように文字列どうしで比べます。変換した結果が数値でないとき(/items/abc など)の扱いも決めておくと、404画面にきれいにつながります。
Q. 開発中は動くのに、公開するとリロードで404になります。
A. Viteの開発サーバーは、存在しないURLに index.html を返す設定が最初から入っています。公開先のサーバーにも同じ設定(Nginxなら try_files、Vercelなら rewrites)を入れると解消します。
まとめ
- React Routerには宣言・データ・フレームワークの3モードがあり、同じ react-router パッケージの使い方の段階になっている
- 宣言モードは
BrowserRouterとRoutes、データモードはcreateBrowserRouterとloader、フレームワークモードはroutes.tsとルートごとのファイルで書く - ネストは「親に
Outlet、子は相対パス、indexは親と同じURL」の3点で組み立てる - 404は
path="*"で受ける。データがないときは、データ・フレームワークモードならloaderでdata(..., { status: 404 })を使う - HTTPステータスまで404にできるのは、サーバーで描画するフレームワークモード(宣言・データモードは200のまま404画面を描く)
- リロードで404になるときは、サーバーで全URLを
index.htmlに向ける。Nginxはtry_files、Vercelはrewrites、アセットのフォルダは分けておく - v6からは import 元を
react-router(RouterProviderはreact-router/dom)に変える。v8ではreact-router-domパッケージがなくなっている
参考
- React Router 公式ドキュメント「Picking a Mode」(https://reactrouter.com/start/modes )
- react-router 8.4.0 パッケージ同梱のドキュメント(docs/start、docs/how-to/spa.md、docs/upgrading/v7.md)と CHANGELOG.md
- Vercel 公式ドキュメント「Vite on Vercel」(https://vercel.com/docs/frameworks/frontend/vite )
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。