たい焼きのイラスト

React Router の Server Components 対応

React Router はプレビュー版として Server Components に対応しました。これにより loader や actions を使用してデータを返す際にコンポーネント渡したり、Server Components ファーストのサーバーコンポーネントルートを作成できるようになりました。この記事では React Router の Server Components 対応を実際に試してみます。

音声による概要

この音声概要は AI によって生成されており、誤りを含む可能性があります。

Note

React Router の Server Components 対応はプレビュー版で提供されています。今後変更される可能性がありますので、注意してください。

React Router はプレビュー版として Server Components に対応しました。これにより以下のような機能が追加されました。

  • loaderactions を使用してデータを返す際にコンポーネント渡せるようになる
  • 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 のサンプルアプリケーションが表示されます。

loaderaction の使用

loader 関数からサーバーコンポーネントを使用する方法を見てみましょう。既存の API と組み合わせてサーバーコンポーネントを使用できるので、従来の React Router の API を使用している場合でも簡単に移行できる点が特徴と言えるでしょう。

TODO リストを API から取得して表示する例を作成してみましょう。app/routes/todo.tsx ファイルを作成します。

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 コンポーネントを指定できるようになった点が新しい機能です。これはサーバーコンポーネントとして扱われクライアント側に送信されます。そのため useStateuseEffect などのクライアントコンポーネントのみで使用できるフックなどは使用できません。

作成したファイルは app/routes.ts でルーティングに追加する必要があります。route() 関数を使用して、パス名と import パスを指定します。

app/routes.ts
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 エクスポートされているコンポーネントはクライアントコンポーネントと考えることができます。このコンポーネントはすべてがバンドルされてブラウザに送信されますし、useStateuseEffect などのクライアントコンポーネント専用のフックを使用することができます。

loader 関数が返すサーバーコンポーネントを描画するだけでは React がサーバーコンポーネントで目指す完全なアーキテクチャとは言えません。結局のところ、クライアントコンポーネントがルート要素として描画されてしまうからです。

サーバーコンポーネントをルート要素として描画するためには、default エクスポートの代わりに ServerComponent という名前のコンポーネントをエクスポートします。先ほど作成した TODO リストの例をサーバーコンポーネントファーストのルートに変更してみましょう。

app/routes/todo.tsx
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 内で useStateuseEffect を呼び出すとサーバーエラーが発生することから、確かにサーバーコンポーネントとして扱われていることがわかります。

サーバーコンポーネントの子孫コンポーネントもすべてサーバーコンポーネントとして扱われます。そのためサーバーコンポーネントをルート要素としたルーティングモジュールで useStateuseEffect を使用したい場合には "use client" ディレクティブを使用してクライアントコンポーネントとして扱う必要があります。

app/routes/Counter.tsx
"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>;
}
app/routes/todo.tsx
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 関数はサーバーコンポーネントのストリーミングレンダリングが開始される前に呼び出されます。そのためリダイレクトを行ったり、適切なヘッダーやステータスコードを送信する目的で使用できます。

app/routes/todo.tsx
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 に以下のコードを追加します。

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 を追加するフォームを作成しましょう。

app/routes/todo.tsx
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 に対応した
  • loaderactions を使用してデータを返す際にコンポーネント渡せるようになった
  • ルートモジュールにおいて ServerComponent という名前のコンポーネントをエクスポートすることでサーバーコンポーネントをルート要素として使用できる
  • "use server" ディレクティブを使用したサーバー関数もサポートされている

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

ルートモジュールにおいてサーバーコンポーネントをルート要素として使用する方法は次のうちどれか

  • コンポーネントをデフォルトエクスポートする

    もう一度考えてみましょう

  • ファイルの先頭で use server ディレクティブを宣言する

    もう一度考えてみましょう

  • ServerComponent という名前のコンポーネントをエクスポートする

    正解!

  • export isServerComponent = true のようなフラグをエクスポートする

    もう一度考えてみましょう