React Server Componentsとは?仕組みと実装パターンをわかりやすく解説

kento_morota 21分で読めます

React Server Components(RSC)は、Reactコンポーネントをサーバー上で実行し、その結果だけをクライアントに送信する仕組みです。Next.js App Routerで本格採用され、パフォーマンスと開発体験の両面で大きな変革をもたらしています。

本記事では、RSCの仕組み、Server ComponentsとClient Componentsの使い分け、データ取得パターン、そして実務での実装ベストプラクティスを解説します。

React Server Componentsの基本概念

React Server Components(RSC)は、コンポーネントの実行場所を「サーバー」と「クライアント」に明確に分離するアーキテクチャです。

従来のReactとRSCの違い

従来のReact(CSR: Client-Side Rendering)では、すべてのコンポーネントがクライアント(ブラウザ)上で実行されていました。サーバーからJavaScriptバンドルを受け取り、ブラウザ上でコンポーネントツリーを構築してDOMをレンダリングします。

RSCでは、サーバー上でコンポーネントを実行し、その結果(HTML + シリアライズされたコンポーネントツリー)をクライアントに送信します。サーバーで実行されるコンポーネントのJavaScriptはクライアントに送られないため、バンドルサイズが大幅に削減されます。

RSCのメリット

バンドルサイズの削減:Server Componentsで使用するライブラリ(マークダウンパーサー、日付フォーマッター、シンタックスハイライターなど)はクライアントに送信されません。数百KBのライブラリをバンドルサイズに影響なく使用できます。

直接的なデータアクセス:Server Componentsはサーバー上で実行されるため、データベースやファイルシステムに直接アクセスできます。APIエンドポイントを経由する必要がありません。

セキュリティ:APIキーやDBの接続情報がクライアントに漏洩するリスクがありません。

初期ロードの高速化:サーバーでレンダリングされたHTMLがすぐに表示されるため、ユーザーの体感速度が向上します。

Server ComponentsとClient Componentsの使い分け

基本的な判断基準

Server Components(デフォルト)を使う場面:

データの取得と表示、静的なUI要素、SEOが重要なコンテンツ、大きなライブラリを使う処理(マークダウン変換、構文ハイライトなど)。

Client Components("use client"が必要)を使う場面:

ユーザーインタラクション(onClick、onChangeなど)、状態管理(useState、useReducer)、副作用(useEffect)、ブラウザAPI(localStorage、window、IntersectionObserverなど)。

// Server Component(デフォルト - "use client"を書かない)
// サーバー上で実行される
async function ArticlePage({ id }: { id: string }) {
  // データベースに直接アクセス可能
  const article = await db.article.findUnique({ where: { id } });

  if (!article) return <NotFound />;

  return (
    <article>
      <h1>{article.title}</h1>
      <ArticleContent content={article.content} />
      {/* Client Componentも子として配置可能 */}
      <LikeButton articleId={id} />
      <CommentSection articleId={id} />
    </article>
  );
}
// Client Component - "use client"ディレクティブが必須
"use client";

import { useState } from "react";

function LikeButton({ articleId }: { articleId: string }) {
  const [liked, setLiked] = useState(false);
  const [count, setCount] = useState(0);

  const handleLike = async () => {
    setLiked(!liked);
    setCount((c) => (liked ? c - 1 : c + 1));

    await fetch(`/api/articles/${articleId}/like`, {
      method: liked ? "DELETE" : "POST",
    });
  };

  return (
    <button onClick={handleLike}>
      {liked ? "♥" : "♡"} {count}
    </button>
  );
}

コンポーネントの境界設計

RSCの設計では、「Client Componentsの境界をできるだけ葉(リーフ)に近づける」ことが重要です。

// 悪い例:ページ全体をClient Componentにしてしまう
"use client"; // ← ページ全体がクライアントで実行される

export default function ProductPage({ id }: { id: string }) {
  const [quantity, setQuantity] = useState(1);
  // ...
}

// 良い例:インタラクティブな部分だけをClient Componentに分離
// ProductPage.tsx(Server Component)
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetchProduct(params.id);

  return (
    <div>
      {/* 静的な部分はServer Component */}
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <ProductImages images={product.images} />

      {/* インタラクティブな部分だけClient Component */}
      <AddToCartForm product={product} />
      <ProductReviews productId={product.id} />
    </div>
  );
}

データ取得パターン

Server Componentでの直接データ取得

// Server Componentではasync/awaitが直接使える
async function UserProfile({ userId }: { userId: string }) {
  // 並列でデータ取得
  const [user, posts, followers] = await Promise.all([
    fetchUser(userId),
    fetchUserPosts(userId),
    fetchFollowers(userId),
  ]);

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{followers.length}フォロワー</p>

      <h2>投稿一覧</h2>
      {posts.map((post) => (
        <PostCard key={post.id} post={post} />
      ))}
    </div>
  );
}

// データ取得関数(サーバー上で実行)
async function fetchUser(id: string): Promise<User> {
  // DBに直接アクセス
  const user = await db.user.findUnique({ where: { id } });
  if (!user) throw new Error("User not found");
  return user;
}

Suspenseによるストリーミング

import { Suspense } from "react";

// 遅いデータ取得を含むコンポーネント
async function RecommendedProducts() {
  // この取得に3秒かかるとする
  const products = await fetchRecommendations();
  return (
    <div>
      {products.map((p) => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

// ページコンポーネント
export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetchProduct(params.id);

  return (
    <div>
      {/* メインコンテンツは即座に表示 */}
      <h1>{product.name}</h1>
      <p>¥{product.price.toLocaleString()}</p>

      {/* おすすめ商品はストリーミングで後から表示 */}
      <Suspense fallback={<p>おすすめ商品を読み込み中...</p>}>
        <RecommendedProducts />
      </Suspense>
    </div>
  );
}

Suspenseを使うことで、遅いデータ取得がページ全体のレンダリングをブロックしません。メインコンテンツが先に表示され、おすすめ商品は準備ができ次第ストリーミングで配信されます。

Server Actionsによるデータ更新

// actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { z } from "zod";

const ContactSchema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.string().email("メールアドレスの形式が不正です"),
  message: z.string().min(10, "10文字以上で入力してください"),
});

export async function submitContact(prevState: any, formData: FormData) {
  const rawData = {
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  };

  const result = ContactSchema.safeParse(rawData);
  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }

  await db.contact.create({ data: result.data });
  revalidatePath("/contacts");

  return { success: true, errors: {} };
}
// ContactForm.tsx
"use client";

import { useActionState } from "react";
import { submitContact } from "./actions";

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, {
    success: false,
    errors: {},
  });

  return (
    <form action={formAction}>
      <div>
        <input name="name" placeholder="名前" />
        {state.errors.name && <span>{state.errors.name[0]}</span>}
      </div>

      <div>
        <input name="email" type="email" placeholder="メールアドレス" />
        {state.errors.email && <span>{state.errors.email[0]}</span>}
      </div>

      <div>
        <textarea name="message" placeholder="メッセージ" />
        {state.errors.message && <span>{state.errors.message[0]}</span>}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "送信中..." : "送信"}
      </button>

      {state.success && <p>送信しました</p>}
    </form>
  );
}

実務でのコンポーネント設計パターン

Container/Presentationalパターン(RSC版)

// Container: Server Component(データ取得を担当)
async function UserListContainer() {
  const users = await db.user.findMany({
    orderBy: { createdAt: "desc" },
    take: 20,
  });

  return <UserListPresentation users={users} />;
}

// Presentation: Server or Client Component(表示を担当)
function UserListPresentation({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <span>{user.name}</span>
          <span>{user.email}</span>
        </li>
      ))}
    </ul>
  );
}

Server ComponentからClient Componentへのデータ受け渡し

// Server Component
async function Dashboard() {
  // サーバーでデータを取得
  const initialData = await fetchDashboardData();

  // Client ComponentにPropsとして渡す
  // 注意: シリアライズ可能な値のみ渡せる(関数やクラスインスタンスは不可)
  return (
    <div>
      <h1>ダッシュボード</h1>

      {/* 初期データをPropsで渡し、クライアントで動的に更新 */}
      <DashboardCharts initialData={initialData.charts} />

      {/* フィルタリングなどインタラクティブな操作が必要 */}
      <DataTable
        initialData={initialData.tableRows}
        columns={initialData.columns}
      />
    </div>
  );
}

// Client Component
"use client";

function DashboardCharts({ initialData }: { initialData: ChartData[] }) {
  const [dateRange, setDateRange] = useState("7d");
  // initialDataを初期値として使い、フィルター変更時にクライアントから再取得
  const { data } = useQuery({
    queryKey: ["charts", dateRange],
    queryFn: () => fetchCharts(dateRange),
    initialData: dateRange === "7d" ? initialData : undefined,
  });

  return (/* ... */);
}

Compositionパターン:Client Componentの中にServer Componentを配置する

// Client Component(レイアウト)
"use client";

function Modal({ children }: { children: React.ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>開く</button>
      {isOpen && (
        <div className="modal-overlay">
          <div className="modal-content">
            {children} {/* Server Componentを子として受け取れる */}
            <button onClick={() => setIsOpen(false)}>閉じる</button>
          </div>
        </div>
      )}
    </>
  );
}

// Server Component(親)
async function ProductDetail({ id }: { id: string }) {
  const product = await fetchProduct(id);

  return (
    <div>
      <h1>{product.name}</h1>
      <Modal>
        {/* Server Componentの結果をchildrenとして渡す */}
        <ProductSpecs specs={product.specs} />
      </Modal>
    </div>
  );
}

パフォーマンス最適化のポイント

データ取得の並列化

// 悪い例:逐次実行(ウォーターフォール)
async function Page() {
  const user = await fetchUser();     // 1秒
  const posts = await fetchPosts();   // 1秒
  const comments = await fetchComments(); // 1秒
  // 合計: 3秒
}

// 良い例:並列実行
async function Page() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),     // 1秒
    fetchPosts(),    // 1秒
    fetchComments(), // 1秒
  ]);
  // 合計: 1秒
}

// さらに良い例:Suspenseでストリーミング
function Page() {
  return (
    <div>
      <Suspense fallback={<UserSkeleton />}>
        <UserSection />
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <PostsSection />
      </Suspense>
      <Suspense fallback={<CommentsSkeleton />}>
        <CommentsSection />
      </Suspense>
    </div>
  );
}

キャッシュ戦略

// Next.jsでのキャッシュ制御
// 静的に生成(ビルド時に実行、キャッシュされる)
async function StaticPage() {
  const data = await fetch("https://api.example.com/data", {
    cache: "force-cache", // デフォルト
  });
}

// 動的にレンダリング(リクエストごとに実行)
async function DynamicPage() {
  const data = await fetch("https://api.example.com/data", {
    cache: "no-store",
  });
}

// 一定時間キャッシュ(ISR: Incremental Static Regeneration)
async function ISRPage() {
  const data = await fetch("https://api.example.com/data", {
    next: { revalidate: 3600 }, // 1時間ごとに再検証
  });
}

まとめ

React Server Componentsは、サーバーとクライアントの役割を明確に分離し、パフォーマンスと開発体験を大幅に向上させるアーキテクチャです。

基本概念:Server Componentsはサーバー上で実行され、JavaScriptバンドルに含まれません。Client Componentsはインタラクティブな部分にのみ使用します。

設計原則:Client Componentsの境界を末端に近づけ、できるだけ多くのコンポーネントをServer Componentとして保つことが重要です。Compositionパターンで、Client Componentの子としてServer Componentの結果を渡すことも可能です。

データ取得:Server Componentsではasync/awaitでDB直接アクセスが可能です。Suspenseでストリーミングし、遅いデータ取得がページ全体をブロックしないようにします。

データ更新:Server Actionsでフォーム送信やデータ変更をサーバー側で処理し、revalidatePathでキャッシュを更新します。

RSCはReactの未来の方向性です。Next.js App Routerを使うプロジェクトでは、まずすべてのコンポーネントをServer Componentとして書き始め、インタラクションが必要な部分だけをClient Componentに変換するアプローチが効果的です。

#React#Server Components#RSC
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう