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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?