はじめに
React Conf 2024 で話題になった React Router v7 がついに2024/11/22に Remix と合流しましたので公式ドキュメントを読みながら情報を整理します。
Graphic by Jacob Paris
React Router v7
React Router v7 の公式ドキュメントを読みながら、ポイントをまとめていきます。
Non-breaking
Upgrading from v6 to v7 is a non-breaking upgrade. Keep using React Router the same way you already do.
v6 から v7 へのアップグレードは非破壊アップグレードです。これまでと同じように React Router を使い続けてください。
Bridge to React 19
All new bundling, server rendering, pre-rendering, and streaming features allow you bridge the gap from React 18 to 19 incrementally.
新しいバンドル、サーバーレンダリング、プリレンダリング、ストリーミング機能により、React 18 から 19 へのギャップを段階的に埋めることができます。
Type Safety
New typegen provides first class types for route params, loader data, actions, and more.
新しい typegen は、ルートパラメータ、ローダーデータ、アクションなどのファーストクラスの型を提供します。
ライブラリとしての React Router
v7 でも引き続き、シンプルで宣言的なルーティングライブラリとして使用できます。URL をコンポーネントと一致させ、URL データへのアクセスを提供し、アプリ内を遷移します。
ReactDOM.createRoot(root).render(
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="dashboard" element={<Dashboard />}>
<Route index element={<RecentActivity />} />
<Route path="project/:id" element={<Project />} />
</Route>
</Routes>
</BrowserRouter>
);
(出典) React Router as a framework
フレームワークとしての React Router
React Router が Remix と統合されたことで Remix の特徴を受け継ぎ 「Web 標準」 に沿うものとなりました。基本的なデータの受け渡しの考え方は以下の図の通りです。
React Router を React フレームワークとして活用できるようになりました。一般的な Web プロジェクトに必要な以下のような多数の機能を提供します。
和訳・意訳 | 原文 |
---|---|
Viteバンドラーと開発サーバーの統合 | Vite bundler and dev server integration |
モジュールの即時適用 | hot module replacement |
コード分割 | code splitting |
型安全性を考慮したルート定義 | route conventions with type safety |
ファイルシステムまたは設定に基づくルーティング | file system or config-based routing |
型安全性を考慮したデータ読み込み | data loading with type safety |
型安全性のあるアクション | actions with type safety |
アクション後のページデータの自動再検証 | automatic revalidation of page data after actions |
SSR、SPA、静的レンダリング | SSR, SPA, and static rendering strategies |
保留状態と楽観的UIのためのAPI | APIs for pending states and optimistic UI |
デプロイメントアダプタ | deployment adapters |
ルーティング
各ルートをコード分割し、パラメーターとデータの型安全性を提供。ユーザーが遷移したときに保留中の状態にアクセスしてデータを自動的に読み込みます。
import {
type RouteConfig,
route,
index,
layout,
prefix,
} from "@react-router/dev/routes";
export default [
index("./home.tsx"),
route("about", "./about.tsx"),
layout("./auth/layout.tsx", [
route("login", "./auth/login.tsx"),
route("register", "./auth/register.tsx"),
]),
...prefix("concerts", [
index("./concerts/home.tsx"),
route(":city", "./concerts/city.tsx"),
route(":city/:id", "./concerts/show.tsx")
route("trending", "./concerts/trending.tsx"),
]),
] satisfies RouteConfig;
ローダー
ローダーはルートコンポーネントにデータを提供します。
// loaders provide data to components
export async function loader({ params }: Route.LoaderArgs) {
const [show, isLiked] = await Promise.all([
fakeDb.find("show", params.id),
fakeIsLiked(params.city),
]);
return { show, isLiked };
}
コンポーネント
コンポーネントは、プロパティとして渡されたローダーデータを使用して、ルーティングで指定された URL にてレンダリングされます。
export default function Show({
loaderData,
}: Route.ComponentProps) {
const { show, isLiked } = loaderData;
return (
<div>
<h1>{show.name}</h1>
<p>{show.description}</p>
<form method="post">
<button
type="submit"
name="liked"
value={isLiked ? 0 : 1}
>
{isLiked ? "Remove" : "Save"}
</button>
</form>
</div>
);
}
アクション
アクションによってデータを更新し、ページ上のすべてのデータの再検証をトリガーにして、UI を自動的に最新の状態に維持することができます。
export async function action({
request,
params,
}: Route.LoaderArgs) {
const formData = await request.formData();
await fakeSetLikedShow(formData.get("liked"));
return { ok: true };
}
(出典)React Router as a framework
loader の深堀り
loader / ルートローダーは、ルートコンポーネントがレンダリングされる前にデータを提供します。 これらはサーバーレンダリング時、またはプリレンダリングによるビルド時にのみサーバー上で呼び出されます。
export async function loader() {
return { message: "Hello, world!" };
}
export default function MyRoute({ loaderData }) {
return <h1>{loaderData.message}</h1>;
}
clientLoader はブラウザでのみ呼び出されます。 route loader
に加えてコンポーネントにデータ提供します。 route loader
の代替としても使えます。
ブラウザでのみ呼び出されるルートクライアントローダーは、ルートローダーに加えて、あるいはルートローダーの代わりに、ルートコンポーネントにデータを提供します。
export async function clientLoader({ serverLoader }) {
// call the server loader
const serverData = await serverLoader();
// And/or fetch data on the client
const data = getDataFromClient();
// Return the data to expose through useLoaderData()
return data;
}
cliendLoader
は hydrate
プロパティを設定することで、サーバ・レンダリングされたページの初期ページロード時のハイドレーションに参加することができます。
constとして使用することで、TypeScriptはclientLoader.hydrateの型がbooleanではなくtrueであることを推測します。 これにより、React RouterはclientLoader.hydrateの値に基づいてloaderDataの型を導出することができます。
export async function clientLoader() {
// ...
}
clientLoader.hydrate = true as const;
クライアントデータの読み込み
clientLoader
クライアントでデータを取得するために使用されます。これは、ブラウザからのみデータを取得したいページやプロジェクト全体に役立ちます。
// route("products/:pid", "./product.tsx");
import type { Route } from "./+types/product";
export async function clientLoader({
params,
}: Route.ClientLoaderArgs) {
const res = await fetch(`/api/products/${params.pid}`);
const product = await res.json();
return product;
}
export default function Product({
loaderData,
}: Route.ComponentProps) {
const { name, description } = loaderData;
return (
<div>
<h1>{name}</h1>
<p>{description}</p>
</div>
);
}
サーバーデータの読み込み
サーバーレンダリングの際、loader
は最初のページロードとクライアントナビゲーションの両方に使用されます。クライアントのナビゲーションは、ブラウザからサーバーへのReact Routerによる自動フェッチ / fetch
を通じて loader
を呼び出します。
ローダー関数 / loader
はクライアント・バンドルから削除されるので、ブラウザに含まれることを気にすることなく、サーバーのみのAPIを使うことができます。
// route("products/:pid", "./product.tsx");
import type { Route } from "./+types/product";
import { fakeDb } from "../db";
export async function loader({ params }: Route.LoaderArgs) {
const product = await fakeDb.getProduct(params.pid);
return product;
}
export default function Product({
loaderData,
}: Route.ComponentProps) {
const { name, description } = loaderData;
return (
<div>
<h1>{name}</h1>
<p>{description}</p>
</div>
);
}
他にも Static Data Loading でプリレンダリングする URL を react-router.config.ts
で指定することができます。
loader
と clientLoader
は一緒に使うことができます。 loader
は最初のSSR(またはプリレンダリング)のためにサーバ上で使用され、clientLoader
はその後のクライアントサイドのナビゲーションのために使用されます。
(番外編:Suspense)
React Suspense を使用したストリーミングにより、アプリは重要でないデータを延期し、UI レンダリングをブロック解除することで初期レンダリングを高速化できます。
React Router は、ローダーとアクションから Promise を返すことで React Suspense をサポートします。
(出典)Route Module > loader / Route Module > clientLoader / Data Loading
action の深堀り
action / ルートアクションを使用すると、<Form>
、useFetcher
、useSubmit
から呼び出されたときに、ページ上のすべての読み込みデータを自動的に再検証して、サーバー側のデータ変更が可能になります。
// route("/list", "./list.tsx")
import { Form } from "react-router";
import { TodoList } from "~/components/TodoList";
// このデータはアクションが完了した後に読み込まれます...
export async function loader() {
const items = await fakeDb.getItems();
return { items };
}
// ...ここでのリストは自動的に更新されます
export default function Items({ loaderData }) {
return (
<div>
<List items={loaderData.items} />
<Form method="post" navigate={false} action="/list">
<input type="text" name="title" />
<button type="submit">Create Todo</button>
</Form>
</div>
);
}
export async function action({ request }) {
const data = await request.formData();
const todo = await fakeDb.addItem({
title: data.get("title"),
});
return { ok: true };
}
clientAction はブラウザからのみ呼び出されるルートアクションです。
export async function clientAction({ serverAction }) {
fakeInvalidateClientSideCache();
// 必要に応じてサーバーアクションを呼び出すことができる
const data = await serverAction();
return data;
}
クライアントアクション
クライアントアクションはブラウザー内でのみ実行され、両方が定義されている場合はサーバーアクションよりも優先されます。
// route('/projects/:projectId', './project.tsx')
import type { Route } from "./+types/project";
import { Form } from "react-router";
import { someApi } from "./api";
export async function clientAction({
request,
}: Route.ClientActionArgs) {
let formData = await request.formData();
let title = await formData.get("title");
let project = await someApi.updateProject({ title });
return project;
}
export default function Project({
actionData,
}: Route.ComponentProps) {
return (
<div>
<h1>Project</h1>
<Form method="post">
<input type="text" name="title" />
<button type="submit">Submit</button>
</Form>
{actionData ? (
<p>{actionData.title} updated</p>
) : null}
</div>
);
}
サーバーアクション
サーバーアクションはサーバー上でのみ実行され、クライアントバンドルからは削除されます。
// route('/projects/:projectId', './project.tsx')
import type { Route } from "./+types/project";
import { Form } from "react-router";
import { fakeDb } from "../db";
export async function action({
request,
}: Route.ActionArgs) {
let formData = await request.formData();
let title = await formData.get("title");
let project = await fakeDb.updateProject({ title });
return project;
}
export default function Project({
actionData,
}: Route.ComponentProps) {
return (
<div>
<h1>Project</h1>
<Form method="post">
<input type="text" name="title" />
<button type="submit">Submit</button>
</Form>
{actionData ? (
<p>{actionData.title} updated</p>
) : null}
</div>
);
}
アクションの呼び出し
action
はルートのパスと「post」メソッドを参照することにより <Form>
と useSubmit
を通じて宣言的に呼び出されます。
※ <fetcher.Form>
と fetcher.submit
も可
アクション後にはナビゲーションが実行され、ブラウザの履歴に追加されます。
例)<Form>
でアクションを呼び出す。
import { Form } from "react-router";
function SomeComponent() {
return (
<Form action="/projects/123" method="post">
<input type="text" name="title" />
<button type="submit">Submit</button>
</Form>
);
}
例)useSubmit
でアクションを呼び出す
import { useCallback } from "react";
import { useSubmit } from "react-router";
import { useFakeTimer } from "fake-lib";
function useQuizTimer() {
let submit = useSubmit();
let cb = useCallback(() => {
submit(
{ quizTimedOut: true },
{ action: "/end-quiz", method: "post" }
);
}, []);
let tenMinutes = 10 * 60 * 1000;
useFakeTimer(tenMinutes, cb);
}
ナビゲーションを発生させず、ブラウザ履歴に残さなずアクションを送信するにはフェッチャーを使用します。
例)useFetcher
でアクションを呼び出す
import { useFetcher } from "react-router";
function Task() {
let fetcher = useFetcher();
let busy = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action="/update-task/123">
<input type="text" name="title" />
<button type="submit">
{busy ? "Saving..." : "Save"}
</button>
</fetcher.Form>
);
}
(出典)Route Module > action / Route Module > clientAction / Actions
ErrorBoundary の深堀り
他のルートモジュールAPIが投げるとき、ルートコンポーネントの代わりにルートモジュールの ErrorBoundary
がレンダリングされます。これはRemix の ErrorBoundary を踏襲しているようです。
Remixの ErrorBoundary
コンポーネントは、通常の React のエラー境界と同じように動作しますが、いくつかの追加機能があります。 ルートコンポーネントにエラーがあると、ErrorBoundary
はその場所にレンダリングされ、親ルートの内部にネストされます。 ErrorBoundary
コンポーネントは、ルートのローダーやアクション関数でエラーが発生したときにもレンダリングされます。
最も一般的なユースケースは次のようなものです。
- UI エラーのトリガーとして意図的に 4xx レスポンスをスローする
- ユーザーの入力に問題があった場合に 400 をスローする
- 不正アクセスに対して 401 をスローする
- リクエストされたデータが見つからない場合に 404 をスローする
- React は、レンダリング中にランタイムエラーと遭遇すると、意図せずに Error をスローすることがあります
スローされたオブジェクトを取得するには、useRouteError
フックを使用します。 Response
がスローされると、ステート/ステータステキスト/データフィールドを持つ ErrorResponse
インスタンスに自動的にアンラップされます。スローされたレスポンスとスローされたエラーを区別するには、isRouteErrorResponse
ユーティリティを使用します。
import {
isRouteErrorResponse,
useRouteError,
} from "react-router";
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
</div>
);
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<p>The stack trace is:</p>
<pre>{error.stack}</pre>
</div>
);
} else {
return <h1>Unknown Error</h1>;
}
}
(出典)Route Module > ErrorBoundary / Remix > ErrorBoundary
Hydrate 深堀り
最初のページロードでは、ルートコンポーネントはクライアントローダが終了した後にのみレンダリングされます。エクスポートされた場合、ルートコンポーネントの代わりに HydrateFallback
を直ちにレンダリングすることができます。
export async function clientLoader() {
const data = await fakeLoadLocalGameData();
return data;
}
export function HydrateFallback() {
return <p>Loading Game...</p>;
}
export default function Component({ loaderData }) {
return <Game data={loaderData} />;
}
(出典)Route Module > HydrateFallback
他にも
headers, links, meta などがあり、SEOやアセット読み込みを意識した部品が提供されているようです。
redirect や useNavigate などは継続して利用できるようです。
Pending UI は新しいページを開いたり、アクションにデータを送信した際の非同期処理を待つ保留状態を扱うものです。
テスト
コンポーネントが useLoaderData
や <Link>
などを使用する場合、React Routerアプリのコンテキストでレンダリングする必要があります。createRoutesStub
関数は、コンポーネントを分離してテストするためのコンテキストを作成します。
createRoutesStub
を使うことでローダー、アクション、コンポーネントを持つルートモジュールに似たオブジェクトを受け取りテストします。
import { useActionData } from "react-router";
export function LoginForm() {
const errors = useActionData();
return (
<Form method="post">
<label>
<input type="text" name="username" />
{errors?.username && <div>{errors.username}</div>}
</label>
<label>
<input type="password" name="password" />
{errors?.password && <div>{errors.password}</div>}
</label>
<button type="submit">Login</button>
</Form>
);
}
import { createRoutesStub } from "react-router";
import * as Test from "@testing-library/react";
import { LoginForm } from "./LoginForm";
test("LoginForm renders error messages", async () => {
const USER_MESSAGE = "Username is required";
const PASSWORD_MESSAGE = "Password is required";
const Stub = createRoutesStub([
{
path: "/login",
Component: LoginForm,
action() {
return {
errors: {
username: USER_MESSAGE,
password: PASSWORD_MESSAGE,
},
};
},
},
]);
// render the app stub at "/login"
Test.render(<Stub initialEntries={["/login"]} />);
// simulate interactions
Test.user.click(screen.getByText("Login"));
await Test.waitFor(() => screen.findByText(USER_MESSAGE));
await Test.waitFor(() =>
screen.findByText(PASSWORD_MESSAGE)
);
});
(出典)Testing
レンダリングの選択肢
React Router は3種類のレンダリングの選択肢を提供します。
CSR (Client Side Rendering)
SSR (Server Side Rendering)
SSG (Static Pre-rendering) *1 筆者はSSGと理解しました
import type { Config } from "@react-router/dev/config";
export default {
ssr: false, // false で CSR, true で SSR
} satisfies Config;
ssr:false
とすると常にクライアントサイドでレンダリングされます。SPA を構築する場合は、サーバーレンダリングを無効にしてください。
サーバーサイドレンダリングには、サーバーへのデプロイが必要です。ssr:true
はグローバルな設定ですが、個々のルートで静的にプリレンダリングできます。また、ルートは clientLoader
でクライアントデータのロードを使用し、UIの一部分のサーバーレンダリング/フェッチを避けることができます。
Client Rendering, Server Rendering の詳細は公式ドキュメントを参照ください。
(出典)Rendering Strategies / Custom Framework
アップグレード
React Router v6 から v7 へのアップグレード
v6 → v7 のアップグレードでは、フューチャーフラグを有効にすれば変更点はありません。フィーチャーフラグを利用して一つずつ段階的に変更を適用しながら進めることができます。
Remix v2 から React Router v7 へのアップグレード
割愛します。公式ドキュメントを参照してください。
おわりに
React フレームワークを選択する上で Next.js や Remix は選択肢に上がると思います。筆者は既に React Router を使っているので v7 にバージョンアップすることで Remix の Web 標準に沿ったフレームワークを使い始める環境が整うので、まずは v7 にバージョンしてみたいと思いました。
(おまけ)
公式ドキュメントを読みながら調べていく中で日本語訳サイトを発見したので共有します。