React Router v7 (Remix) で遊ぶ

はじめに
Next.js や TanStack Router を試してみたが自分には React Router が合っている感じがしたので、このスクラップでは React Router v7 で遊ぶ過程を記録していこう。

プロジェクト作成
cd ~/workspace
npx create-react-router@latest my-react-router-app
cd my-react-router-app
npm i
npm run dev

Routing
app/routes.ts でルーティングが行われる。
import { type RouteConfig, index } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig;
satisfies
とは何だろう?
こちらの記事がわかりやすかった、感謝。

キーワードのピックアップ
- index
- route
- layout
- prefix

ファイルシステムルーティング
@react-router/fs-routes をインストールすることで可能になる。
一応インストールしておこう。
npm install @react-router/fs-routes

Route module
routes.ts で参照されるファイルのこと、ルートの振る舞いを定義する。
loader, action, コンポーネントをエクスポートする。
// provides type safety/inference
import type { Route } from "./+types/team";
// provides `loaderData` to the component
export async function loader({ params }: Route.LoaderArgs) {
let team = await fetchTeam(params.teamId);
return { name: team.name };
}
// renders after the loader is done
export default function Component({
loaderData,
}: Route.ComponentProps) {
return <h1>{loaderData.name}</h1>;
}

コードベースとファイルシステムベースどっちが良い?
ファイルシステムベースの方が良いのかな?
それでもコードベースのしくみを知っていた方が後々役に立ちそうだ。

次は Nested routes について調べよう

Nested routes
import {
type RouteConfig,
route,
index,
} from "@react-router/dev/routes";
export default [
// parent route
route("dashboard", "./dashboard.tsx", [
// child routes
index("./home.tsx"),
route("settings", "./settings.tsx"),
]),
] satisfies RouteConfig;
親のパスは子にも含まれるようだ、なので下記 2 つのパスが出来上がる。
- /dashboard
- /dashboard/settings
また、dashboard.tsx にある Outlet コンポーネントに描画されるようになるようだ。
なんかレイアウトっぽい感じだ。

Root Route
app/root.tsx は特別なファイルであり、すべてのルートモジュールに適用されるようだ。

Layout routes
import {
type RouteConfig,
route,
layout,
index,
prefix,
} from "@react-router/dev/routes";
export default [
layout("./marketing/layout.tsx", [
index("./marketing/home.tsx"),
route("contact", "./marketing/contact.tsx"),
]),
...prefix("projects", [
index("./projects/home.tsx"),
layout("./projects/project-layout.tsx", [
route(":pid", "./projects/project.tsx"),
route(":pid/edit", "./projects/edit-project.tsx"),
]),
]),
] satisfies RouteConfig;
Nested routes に似ているが、パスが追加されないのが特徴のようだ。
なので引数もルートモジュールのみの 1 つになっている。

Index Routes
import {
type RouteConfig,
route,
index,
} from "@react-router/dev/routes";
export default [
// renders into the root.tsx Outlet at /
index("./home.tsx"),
route("dashboard", "./dashboard.tsx", [
// renders into the dashboard.tsx Outlet at /dashboard
index("./dashboard-home.tsx"),
route("settings", "./dashboard-settings.tsx"),
]),
] satisfies RouteConfig;
これもわかりやすい。

Route Prefixes
import {
type RouteConfig,
route,
layout,
index,
prefix,
} from "@react-router/dev/routes";
export default [
layout("./marketing/layout.tsx", [
index("./marketing/home.tsx"),
route("contact", "./marketing/contact.tsx"),
]),
...prefix("projects", [
index("./projects/home.tsx"),
layout("./projects/project-layout.tsx", [
route(":pid", "./projects/project.tsx"),
route(":pid/edit", "./projects/edit-project.tsx"),
]),
]),
] satisfies RouteConfig;
これもわかりやすい、ただ単にプレフィックスを追加するだけのようだ。

Dynamic Segments
route("teams/:teamId", "./team.tsx"),
import type { Route } from "./+types/team";
export async function loader({ params }: Route.LoaderArgs) {
// ^? { teamId: string }
}
export default function Component({
params,
}: Route.ComponentProps) {
params.teamId;
// ^ string
}
ちなみにこれはコード補完が効くのかな? → 試してみたら効いた。
どうやら .react-router というディレクトリに情報が格納されているようだ。

Optional Segments
route(":lang?/categories", "./categories.tsx")
や route("users/:userId/edit?", "./user.tsx")
のように ?
を使うとセグメントをオプショナルにできる。
/categories
や /users/xxxx
でもアクセスできるようになるということだろう。

Splats
*
を使うことで catch-all なセグメントを作れる。
/
以外の文字なら日本語などでもいけるのだろうか? → 日本語どころか /
もいけた。

Component Routes
Remix の Component Routes について教えてください。
いまいちピンとこない。
これはどういう時に使うんだろう、Optional セグメントや Splats と組み合わせるのだろうか?
loader や action が使えなくなるようなので、あまり使わない方が良さそうだ。

次は Route Module から
このページも楽しそうだ。

Route Module
routes.ts から参照されるファイルは Route Module と呼ばれ、次のような定義を含む。
- 自動コード分割
- データロード
- アクション
- リバリデーション
- エラー境界

Component (default)
export default
されるコンポーネントがルートマッチ時に描画される。
export default function MyRouteComponent() {
return (
<div>
<h1>Look ma!</h1>
<p>
I'm still using React Router after like 10 years.
</p>
</div>
);
}

loader
loader はコンポーネントの描画前に呼び出され、コンポーネントにデータを提供する。
サーバーレンダリング時または事前ビルド中にサーバーでのみ実行される。
export async function loader() {
return { message: "Hello, world!" };
}
export default function MyRoute({ loaderData }) {
return <h1>{loaderData.message}</h1>;
}
これもしっかりコード補完が効くんだろうな。

clientLoader
ブラウザでのみ呼び出され、loader に加えて or 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;
}
Client loaders can participate in initial page load hydration of server rendered pages by setting the hydrate property on the function:
これがよくわからん。
きっといつか必要な時が来るのだろう。

action
サーバーサイドでデータ更新を行うときに使用する、更新後は loader や clientLoader が自動的に実行される。
Form、useFetcher、useSubmit のいずれかから呼び出せる。
// route("/list", "./list.tsx")
import { Form } from "react-router";
import { TodoList } from "~/components/TodoList";
// this data will be loaded after the action completes...
export async function loader() {
const items = await fakeDb.getItems();
return { items };
}
// ...so that the list here is updated automatically
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 から

clientAction
ブラウザ内から呼び出される action のようなものらしい、使い所がわからない。
export async function clientAction({ serverAction }) {
fakeInvalidateClientSideCache();
// can still call the server action if needed
const data = await serverAction();
return data;
}
このページにユースケースが書いてあるので参考になった。

ErrorBoundary
エラー発生時に描画されるコンポーネントのようだ。
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>;
}
}

HydrateFallback
最初のページロード時、clientLoader が完了してからコンポーネントが描画されるが、HydrateFallback をエクスポートすることで clientLoader の完了を待たずに直ちに描画されるようになるようだ。
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} />;
}

headers
これはわかりやすい。
export function headers() {
return {
"X-Stretchy-Pants": "its for fun",
"Cache-Control": "max-age=300, s-maxage=3600",
};
}

handle
これは全くもって謎だ。
export const handle = {
its: "all yours",
};
useRouteMatch フックと組み合わせて使うようだ。
任意のデータを格納できるのでパンくずリストを作成する時に便利そうだ。

links
これもわかりやすい。
export function links() {
return [
{
rel: "icon",
href: "/favicon.png",
type: "image/png",
},
{
rel: "stylesheet",
href: "https://example.com/some/styles.css",
},
{
rel: "preload",
href: "/images/banner.jpg",
as: "image",
},
];
}

meta
これもわかりやすいので割愛しよう。

shouldRevalidate
デフォルトではアクション実行後に loader が再実行されるが、この関数を使うことによって再実行するかどうかを制御できるようだ。
import type { ShouldRevalidateFunctionArgs } from "react-router";
export function shouldRevalidate(
arg: ShouldRevalidateFunctionArgs
) {
return true;
}

ハイドレーションについておさらい
ハイドレーションって何となく理解しているつもりだけど、自分の言葉では説明できない。
- サーバーでコンポーネントが描画される
- クライアントに送信される
- クライアントでイベントハンドラなどを実行できるようにクライアントで描画する
- サーバーの描画結果をクライアント描画結果で置き換える
こんな感じなのだろうか?
ハイドレーションとは?
ハイドレーションとは、サーバーで事前に生成された HTML コンテンツに対して、クライアント(ブラウザ)側で JavaScript を利用してインタラクティブな動作を付与するプロセスを指します。サーバーサイドレンダリング (SSR)
サーバーが初期 HTML を生成してクライアントに送信します。この時点で、HTML はすでに完成しており、ユーザーはすぐに内容を視認できます。ハイドレーション
クライアント側で JavaScript がロードされ、サーバーから送られてきた HTML を基に、アプリケーションの状態やイベントリスナーなどの機能を復元します。このステップを「ハイドレーション」と呼びます。
最後は置き換えではなく同期のようだ。
これを踏まえて下記を翻訳してみる。
Client loaders can participate in initial page load hydration of server rendered pages by setting the hydrate property on the function:
hydrate プロパティを設定することで、クライアントローダーはサーバーが描画したページの最初のページロードのハイドレーションに参加することができる。
HydrateFallback の説明を見るに、何もしないとクライアントローダーの実行が完了するまでコンポーネントは描画されないのだろう。
一方、hydrate プロパティを指定することでサーバーローダーの実行が完了したら即コンポーネントを描画できるようになる。
こんな感じだろうか?

検証用のコードを書いてみた
import { Link, useLoaderData } from "react-router";
import type { Route } from "./+types/hydrate";
export async function loader() {
await new Promise((resolve) => setTimeout(resolve, 100));
return { server: true };
}
export async function clientLoader() {
await new Promise((resolve) => setTimeout(resolve, 1000));
return { client: true };
}
// clientLoader.hydrate = true as const;
export default function Hydrate({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>Hydrate</h1>
<pre>{JSON.stringify(loaderData)}</pre>
<Link to="/hydrate">Hydrate</Link>
</div>
);
}
hydrate を true にすると 1 秒経過後に loaderData が clientLoader のものになる。
hydrate を false にすると loaderData は loader のものになるが、Hydrate リンクをクリックすると 1 秒経過後に loaderData が clientLoader のものになる。
なるほど、ちょっとわかったかも知れない。

次は Rendering Strategies