
React Router の Server Components 対応
React Router はプレビュー版として Server Components に対応しました。これにより loader や actions を使用してデータを返す際にコンポーネント渡したり、Server Components ファーストのサーバーコンポーネントルートを作成できるようになりました。この記事では React Router の Server Components 対応を実際に試してみます。
音声による概要
この音声概要は AI によって生成されており、誤りを含む可能性があります。
React Router の Server Components 対応はプレビュー版で提供されています。今後変更される可能性がありますので、注意してください。
React Router はプレビュー版として Server Components に対応しました。これにより以下のような機能が追加されました。
- loader や actions を使用してデータを返す際にコンポーネント渡せるようになる
- Server Components ファーストのサーバーコンポーネントルート
"use server"
ディレクティブを使用したサーバー関数のサポート
この記事では React Router の Server Components 対応を実際に試してみます。
React Router プロジェクトの作成
まずは React Router のサーバーコンポーネント対応はプレビュー版で提供されている機能です。これを使用するために https://github.com/jacob-ebey/experimental-parcel-react-router-starter リポジトリをクローンします。現在 Vite ではサーバーコンポーネントをサポートしていないため、Parcel が使用されています。
git clone https://github.com/jacob-ebey/experimental-parcel-react-router-starter.git
依存関係をインストールして、開発サーバーを起動します。
cd experimental-parcel-react-router-starter
pnpm install
pnpm dev
http://localhost:3000 にアクセスすると、React Router のサンプルアプリケーションが表示されます。
loader
と action
の使用
loader
関数からサーバーコンポーネントを使用する方法を見てみましょう。既存の API と組み合わせてサーバーコンポーネントを使用できるので、従来の React Router の API を使用している場合でも簡単に移行できる点が特徴と言えるでしょう。
TODO リストを API から取得して表示する例を作成してみましょう。app/routes/todo.tsx
ファイルを作成します。
import type { Route } from "./+types/todo"; // この型定義は自動生成される
type Todo = {
userId: number;
id: number;
title: string;
completed: boolean;
};
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span>{todo.title}</span>
<span>{todo.completed ? "✔️" : "❌"}</span>
</li>
))}
</ul>
);
}
// loader 関数は React Router によってサーバー側で自動で呼び出される
// クライアントのバンドルに含まれることはない
export async function loader() {
const json = await fetch("https://jsonplaceholder.typicode.com/todos");
const todos = (await json.json()) as Todo[];
return {
todoCount: todos.length,
// loader 関数の戻り値として React コンポーネントを返す
// これはサーバーコンポーネントとして扱われる
content: <TodoList todos={todos} />,
};
}
// loader 関数が返した値は TodoPage コンポーネントの props として渡される
export default function TodoPage({ loaderData }: Route.ComponentProps) {
return (
<div>
<h1>Todo List</h1>
<p>Todo Count: {loaderData.todoCount}</p>
{/* loader 関数から返されたサーバーコンポーネントを表示 */}
{loaderData.content}
</div>
);
}
ルーティングファイルにおいて loader
という名前の関数を名前付きエクスポートすることで、初期ページの読み込み時やクライアントナビゲーション時にサーバー側で呼び出されます。この関数はクライアントのバンドルに含まれることはないので、サーバー専用の API を呼び出すことができます。
loader
関数の戻り値は export default
でエクスポートしたコンポーネントの props として渡されます。ここで返す値に React コンポーネントを指定できるようになった点が新しい機能です。これはサーバーコンポーネントとして扱われクライアント側に送信されます。そのため useState
や useEffect
などのクライアントコンポーネントのみで使用できるフックなどは使用できません。
作成したファイルは app/routes.ts
でルーティングに追加する必要があります。route()
関数を使用して、パス名と import パスを指定します。
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("about", "routes/about.tsx"),
route("todo", "routes/todo.tsx"),
] satisfies RouteConfig;
それでは http://localhost:3000/todo にアクセスして確認してみましょう。API から取得した TODO リストが表示されるはずです。
サーバーコンポーネントファーストのルート
現在の React Router のルートモジュールから default
エクスポートされているコンポーネントはクライアントコンポーネントと考えることができます。このコンポーネントはすべてがバンドルされてブラウザに送信されますし、useState
や useEffect
などのクライアントコンポーネント専用のフックを使用することができます。
loader
関数が返すサーバーコンポーネントを描画するだけでは React がサーバーコンポーネントで目指す完全なアーキテクチャとは言えません。結局のところ、クライアントコンポーネントがルート要素として描画されてしまうからです。
サーバーコンポーネントをルート要素として描画するためには、default
エクスポートの代わりに ServerComponent
という名前のコンポーネントをエクスポートします。先ほど作成した TODO リストの例をサーバーコンポーネントファーストのルートに変更してみましょう。
type Todo = {
userId: number;
id: number;
title: string;
completed: boolean;
};
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span>{todo.title}</span>
<span>{todo.completed ? "✔️" : "❌"}</span>
</li>
))}
</ul>
);
}
export async function ServerComponent() {
const json = await fetch("https://jsonplaceholder.typicode.com/todos");
const todos = (await json.json()) as Todo[];
return (
<div>
<h1>Todo List</h1>
<TodoList todos={todos} />
</div>
);
}
http://localhost:3000/todo にアクセスすると、変わらず TODO リストが表示されていることが確認できます。ServerComponent
内で useState
や useEffect
を呼び出すとサーバーエラーが発生することから、確かにサーバーコンポーネントとして扱われていることがわかります。
サーバーコンポーネントの子孫コンポーネントもすべてサーバーコンポーネントとして扱われます。そのためサーバーコンポーネントをルート要素としたルーティングモジュールで useState
や useEffect
を使用したい場合には "use client"
ディレクティブを使用してクライアントコンポーネントとして扱う必要があります。
"use client";
import { useState, useEffect } from "react";
export function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, []);
return <div>Count: {count}</div>;
}
import { Counter } from "./Counter";
export async function ServerComponent() {
const json = await fetch("https://jsonplaceholder.typicode.com/todos");
const todos = (await json.json()) as Todo[];
return (
<div>
<h1>Todo List</h1>
<Counter />
<TodoList todos={todos} />
</div>
);
}
なお、ServerComponent
は引き続き loader
関数と組み合わせて使用することが可能です。loader
関数はサーバーコンポーネントのストリーミングレンダリングが開始される前に呼び出されます。そのためリダイレクトを行ったり、適切なヘッダーやステータスコードを送信する目的で使用できます。
import { redirect, data } from "react-router";
import type { Route } from "./+types/todo";
export async function loader({request}: Route.LoaderArgs) {
const cookie = request.headers.get("cookie");
const user = await getUser(cookie);
if (!user) {
// ログインしていない場合はリダイレクトする
return redirect("/login");
}
const isAuthorized = await checkAuthorization(user);
if (!isAuthorized) {
// 認可されていない場合はエラーページを表示する
throw data("Unauthorized", {
status: 403,
});
}
return {}
}
export async function ServerComponent() {
// ...
}
サーバー関数
React のサーバー関数もサポートされています。サーバー関数を定義するためには、use server ディレクティブを使用します。
新しい TODO リストを追加するフォームを作成してみましょう。まずはダミーの TODO リストを取得・作成する関数を作成しましょう。app/routes/db.ts
に以下のコードを追加します。
"use server";
export type Todo = {
id: number;
title: string;
completed: boolean;
};
const db: Todo[] = [
{
id: 1,
title: "卵を買う",
completed: false,
},
{
id: 2,
title: "牛乳を買う",
completed: true,
},
];
export async function fetchTodos() {
return Promise.resolve(db);
}
async function insertTodo(todo: Todo) {
return new Promise((resolve) => {
setTimeout(() => {
db.push(todo);
resolve(todo);
}, 1000);
});
}
// ファイルの先頭で "use server" を指定することで、サーバー関数として扱われる
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
const newTodo: Todo = {
id: db.length + 1,
title,
completed: false,
};
await insertTodo(newTodo);
}
createTodo
関数はサーバー関数として <form>
要素の action
属性に指定されます。モジュールの先頭で "use server"
ディレクティブを指定しているため、この関数はサーバーサイドで実行されます。
app/routes/todo.tsx
ファイルを編集して TODO を追加するフォームを作成しましょう。
import { Todo, fetchTodos, createTodo } from "./db";
function TodoList({ todos }: { todos: Todo[] }) {
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<span>{todo.title}</span>
<span>{todo.completed ? "✔️" : "❌"}</span>
</li>
))}
</ul>
);
}
export async function ServerComponent() {
const todos = await fetchTodos();
return (
<div>
<h1>Todo List</h1>
<form action={createTodo}>
<input type="text" name="title" />
<button type="submit">追加</button>
</form>
<TodoList todos={todos} />
</div>
);
}
フォームを送信すると React によりフォームがリセットされ、createTodo
関数が呼び出されます。サーバー関数が呼び出され後、React Router はルートを再検証し新しいデータで UI の再レンダリングを行います。煩わしいキャッシュの無効化やデータの再取得などは意識する必要がありません。
フォームを送信すると、新しくリストが追加されることが確認できます。
まとめ
- React Router はプレビュー版として Server Components に対応した
loader
やactions
を使用してデータを返す際にコンポーネント渡せるようになった- ルートモジュールにおいて
ServerComponent
という名前のコンポーネントをエクスポートすることでサーバーコンポーネントをルート要素として使用できる "use server"
ディレクティブを使用したサーバー関数もサポートされている