32blogby StudioMitsu
react11 min read

React Server Componentsを完全理解する

RSCの仕組み・Client Componentsとの違い・いつ使うべきかを実例で解説。Next.js App Routerでの実践パターンも紹介。

reactRSCserver-componentsnext.jsapp-router
目次

React Server Components(RSC)が登場したとき、正直よくわからなかった。「サーバーでレンダリングするのはSSRと同じじゃないの?」「どこに書けばいいの?」と混乱したのは僕だけじゃないはずだ。

この記事では、RSCの仕組みをゼロから理解して、Client Componentsとの使い分けをマスターするところまで連れていく。Next.js App Routerを使った実践パターンも含めて、「なんとなく動いている」から「理由を理解して書ける」状態に変わるはずだ。

RSCとは何か?SSRと何が違う?

RSCを一言で説明すると「サーバー上でのみ実行され、クライアントにJavaScriptが送られないコンポーネント」だ。

SSRとRSCの違い

SSR(Server-Side Rendering)は従来からある仕組みで、サーバーでHTMLを生成してクライアントに送り、そのあとJavaScriptを読み込んでReactが「乗っ取る」(ハイドレーション)。クライアントにはコンポーネントのJavaScriptコードが全て送られる。

RSCはこれとは根本的に違う。

比較軸SSRRSC
実行場所サーバー(HTML生成)+ クライアント(ハイドレーション)サーバーのみ
クライアントへのJS送信あり(コンポーネントコードが全て含まれる)なし
インタラクティビティ可能(ハイドレーション後)不可
データベース直接アクセス工夫が必要そのまま可能
バンドルサイズへの影響あるゼロ

RSCのコンポーネントはサーバーで実行されてHTMLに変換され、JavaScriptとしてはクライアントに届かない。つまり、import したnpmライブラリ(たとえば重い日付処理ライブラリ)もバンドルサイズに加算されない。

RSCが解決する問題

tsx
// ❌ 従来のアプローチ(クライアントでデータフェッチ)
"use client";
import { useEffect, useState } from "react";

function ProductList() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch("/api/products").then(res => res.json()).then(setProducts);
  }, []);

  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}

このアプローチの問題:

  • クライアントで追加のリクエストが発生する(ウォーターフォール)
  • ローディング状態の管理が必要
  • コンポーネントのJSがバンドルに含まれる
tsx
// ✅ RSCを使ったアプローチ
// app/products/page.tsx(デフォルトでServer Component)
import { db } from "@/lib/db";

async function ProductList() {
  // データベースに直接アクセス。APIレイヤー不要
  const products = await db.product.findMany();

  return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
  • 追加のリクエストなし(サーバーで直接DBアクセス)
  • ローディング状態の管理不要
  • コンポーネントJSはクライアントに届かない

Server ComponentとClient Componentの使い分け

Next.js App Routerでは、デフォルトで全コンポーネントはServer Componentになる。Client Componentにするには "use client" ディレクティブが必要だ。

Client Componentが必要なとき

tsx
"use client"; // これがないとエラーになる

import { useState, useEffect } from "react";

// ✅ Client Componentが必要なケース
export function Counter() {
  const [count, setCount] = useState(0); // stateが必要
  return (
    <button onClick={() => setCount(c => c + 1)}>
      カウント: {count}
    </button>
  );
}

Client Componentを使うべき場面:

  • useStateuseReduceruseEffect などのHooksを使う
  • ブラウザのAPIにアクセスする(windowlocalStoragenavigator など)
  • クリック・フォーム送信・スクロールなどのイベントハンドラを使う
  • アニメーション(Framer Motionなど)を使う

Server Componentが向いているとき

tsx
// app/blog/[slug]/page.tsx
import { db } from "@/lib/db";
import { marked } from "marked"; // 重い変換ライブラリもバンドルサイズゼロ

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  // DBに直接アクセス
  const post = await db.post.findUnique({
    where: { slug },
  });

  if (!post) return <div>記事が見つかりません</div>;

  // markedをサーバーでだけ使う。クライアントにJSは送られない
  const html = marked(post.content);

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </article>
  );
}

Server Componentが向いている場面:

  • データベースやAPIからデータを取得する
  • 認証トークン・APIキーなどの秘密情報にアクセスする
  • 大きなnpmライブラリをバンドルサイズなしで使う
  • HTMLをレンダリングするだけでインタラクションが不要

Server ComponentとClient Componentの組み合わせ方

重要なルールがある。Server ComponentはClient Componentの子として渡せるが、Client ComponentはServer Componentを直接インポートできない

❌ できないパターン

tsx
"use client";
// ❌ Client Component内でServer Componentをimportしてはいけない
import { ServerSideWidget } from "./ServerSideWidget"; // これはエラー

export function ClientShell() {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(o => !o)}>開く</button>
      {open && <ServerSideWidget />} {/* ❌ Client内でServer Componentは動かない */}
    </div>
  );
}

✅ 正しいパターン(children propで渡す)

tsx
// ClientShell.tsx
"use client";

import { ReactNode, useState } from "react";

export function ClientShell({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(o => !o)}>開く</button>
      {open && children} {/* ✅ childrenとして受け取る */}
    </div>
  );
}
tsx
// app/page.tsx(Server Component)
import { ClientShell } from "./ClientShell";
import { ServerSideWidget } from "./ServerSideWidget"; // Server Component

export default function Page() {
  return (
    // ✅ Server ComponentでCompositionする
    <ClientShell>
      <ServerSideWidget />
    </ClientShell>
  );
}

この「childrenパターン」はRSCを使ううえで最重要のパターンだ。Client Componentの中に「穴」(children)を作って、そこにServer Componentを渡す。

実際のプロジェクトでの構成例

app/
├── page.tsx              # Server Component(データ取得)
├── components/
│   ├── ProductCard.tsx   # Server Component(静的な表示)
│   ├── AddToCart.tsx     # "use client"(クリックハンドラが必要)
│   └── SearchBox.tsx     # "use client"(inputのstate管理)
tsx
// app/page.tsx
import { db } from "@/lib/db";
import { ProductCard } from "./components/ProductCard";
import { SearchBox } from "./components/SearchBox";

export default async function HomePage() {
  const products = await db.product.findMany({ take: 10 });

  return (
    <main>
      <SearchBox /> {/* Client Component */}
      <div className="grid grid-cols-3 gap-4">
        {products.map(product => (
          <ProductCard key={product.id} product={product}>
            {/* AddToCartはClient Componentだが、productデータはサーバーから渡す */}
            <AddToCart productId={product.id} />
          </ProductCard>
        ))}
      </div>
    </main>
  );
}

Server Actionと組み合わせる

RSCはServer Actionとセットで使うことが多い。フォームの送信やデータの変更をサーバー側の関数として定義できる。

tsx
// app/actions.ts
"use server"; // Server Action

import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";

export async function addProduct(formData: FormData) {
  const name = formData.get("name") as string;
  const price = Number(formData.get("price"));

  await db.product.create({ data: { name, price } });
  revalidatePath("/products"); // キャッシュを無効化して再レンダリング
}
tsx
// app/products/new/page.tsx(Server Component)
import { addProduct } from "../actions";

export default function NewProductPage() {
  return (
    <form action={addProduct}>
      <input name="name" placeholder="商品名" />
      <input name="price" type="number" placeholder="価格" />
      <button type="submit">追加</button>
    </form>
  );
}

Suspenseとストリーミング

RSCはReactのSuspenseと組み合わせてストリーミングレンダリングができる。重いデータ取得があっても、その部分だけ遅延させてUIを段階的に表示できる。

tsx
// app/dashboard/page.tsx
import { Suspense } from "react";
import { HeavyStats } from "./components/HeavyStats"; // 重いデータ取得

export default function DashboardPage() {
  return (
    <div>
      <h1>ダッシュボード</h1>

      {/* すぐ表示 */}
      <QuickSummary />

      {/* HeavyStatsのデータ取得が完了するまでfallbackを表示 */}
      <Suspense fallback={<div>統計情報を読み込み中...</div>}>
        <HeavyStats />
      </Suspense>
    </div>
  );
}
tsx
// app/dashboard/components/HeavyStats.tsx(Server Component)
async function HeavyStats() {
  // 重いデータ取得(2秒かかるとしても、ページ全体をブロックしない)
  const stats = await fetchExpensiveStats();

  return (
    <div>
      <p>総売上: {stats.revenue}</p>
      <p>ユーザー数: {stats.users}</p>
    </div>
  );
}

ページ全体のロードをブロックすることなく、UIを段階的にクライアントに届けられる。

まとめ

React Server Componentsは「JavaScriptをクライアントに送らないコンポーネント」だ。

使うべき場面Server ComponentClient Component
データフェッチ避ける
DB直接アクセス❌ 不可
秘密情報の使用❌ 危険
useState/useEffect❌ 不可
イベントハンドラ❌ 不可
ブラウザAPI❌ 不可

App Routerでは「デフォルトServer、必要なときだけClient」の思考で進めると自然に最適な構成になる。迷ったらServer Componentを選び、Hooksやイベントハンドラが必要になったタイミングで "use client" を追加すればいい。

childrenパターンを使いこなせれば、インタラクティブな要素とサーバー側のデータ取得を両立できる。RSCはバンドルサイズの削減とデータフェッチの簡略化に大きく貢献するので、App Routerを使うなら積極的に活用したい。