⚛️ React Boundary
発展 — 🖥️ Server Components

Server Componentsの仕組み:RSCはどこで何をするのか

React Server Components(RSC)とSSRは何が違うのか。 "use client"は何を意味するのか。 RSC Payloadとはどんなデータなのか。RSCの全体像を解き明かします。

🔥 問題提起:JavaScriptバンドルが大きくなりすぎる問題

現代のWebアプリケーションのJavaScriptバンドルは肥大化しています。 MarkdownをレンダリングするためにMarkdownパーサーを、日付を表示するために日付ライブラリを、 これらすべてをブラウザにダウンロードさせています。

従来のReactアプリの問題

// Client-side Reactの従来のアプローチ
import { unified }   from 'unified';          // 60KB
import remarkParse   from 'remark-parse';     // 40KB
import remarkHtml    from 'remark-html';      // 20KB
import { format }    from 'date-fns';         // 200KB(使う関数1つでも)
import { highlight } from 'highlight.js';     // 400KB

function BlogPost({ post }) {
  const html = unified()
    .use(remarkParse)
    .use(remarkHtml)
    .processSync(post.content)
    .toString();
  
  // これら全ライブラリがブラウザにダウンロードされる
  // → 合計700KB以上のJSを全ユーザーが毎回ダウンロード
  return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
  • ブログの記事一覧などのコンテンツは静的なのに毎回クライアントでレンダリング
  • 使うだけのライブラリがバンドルに含まれバンドルサイズが肥大化
  • 特に初回ロード時のTTI(Time to Interactive)が遅くなる
  • インタラクションを持たないコンポーネントのJSがクライアントに届く

🎯 結論から言う

RSCはサーバーでのみ実行されるコンポーネントです。

そのJavaScriptはブラウザに届かず、出力(RSC Payload)だけがブラウザに送られます。

🖥️
Server Component
サーバーのみで実行
JSバンドルに含まれない
🌐
Client Component
ブラウザで実行
JSバンドルに含まれる
📡
RSC Payload
サーバー→クライアント
シリアライズされたUIデータ

🗺️ RSCとSSRは何が違うのか

RSCとSSRは「サーバーで処理する」という点で似ていますが、目的も仕組みも根本的に異なります。 この2つの混同がRSCを理解する最大の障壁です。

SSR vs RSC:根本的な違い
SSR(Server-Side Rendering)
RSC(React Server Components)
目的
初回HTMLを高速に届ける(SEO・FCP)
JSバンドルサイズを削減する
出力形式
HTML文字列
RSC Payload(独自フォーマット)
Hydration
必要(クライアントで同じコンポーネントを再実行)
不要(RSC自体はブラウザで実行しない)
実行タイミング
リクエスト時(毎回)
リクエスト時 or ビルド時
useState使用
可能(Hydration後)
不可

SSRとRSCは補完関係

SSRはHTMLを事前生成してFCPを速くする技術。RSCはサーバー専用コンポーネントでバンドルを削減する技術。 Next.js App RouterではSSRとRSCを組み合わせています。 RSCはSSRのHTMLに組み込まれ、同時にRSC Payloadも送られてClient ComponentのHydrationに使われます。

🚪 "use client"ディレクティブの意味

"use client"は「このファイルはClient Componentである」という宣言ではなく、 「サーバーとクライアントのモジュールグラフの境界線」を意味します。

"use client"の正しい理解
// app/page.tsx(Server Component - デフォルト)
// "use client"がないためServer Component
import { LikeButton } from './LikeButton'; // Client Componentをインポート

async function BlogPage({ params }) {
  // ✅ Server ComponentはasyncにできDBに直接アクセス可能
  const post = await db.post.findUnique({
    where: { id: params.id }
  });
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {/* Client Componentにpropsを渡す → シリアライズ可能な値のみ */}
      <LikeButton postId={post.id} initialLikes={post.likes} />
    </article>
  );
}

// app/LikeButton.tsx(Client Component)
"use client"; // ← この境界線から下はクライアントのモジュールグラフ

import { useState } from 'react'; // useStateはClient Componentで使用可能

export function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  
  const handleLike = async () => {
    setLikes(l => l + 1);
    await fetch(`/api/like/${postId}`, { method: 'POST' });
  };
  
  return (
    <button onClick={handleLike}>
      ❤️ {likes}
    </button>
  );
}

⚠️ "use client"の伝播

"use client"はファイルに書いたコンポーネントだけでなく、 そこからimportされているすべてのモジュールも「クライアント側」になります。 "use client"ファイルからimportされたコンポーネントは自動的にClient Componentになり、 そのJavaScriptはバンドルに含まれます。

"use client"境界のモジュールグラフ
// Server Component(バンドルに含まれない)
app/page.tsx
  └── app/ArticleContent.tsx    (Server Component)
  └── app/LikeButton.tsx        "use client" ← 境界線
        └── app/ui/Button.tsx   (Client Componentになる)
        └── lib/analytics.ts    (Client Componentになる)

// LikeButton.tsxで"use client"を宣言すると:
// - Button.tsx, analytics.ts もクライアントバンドルに含まれる
// - これらのファイルに"use client"を書く必要はない(自動的に)

📦 RSC Payload:サーバーからクライアントへのデータ

RSC Payloadは、Server ComponentのレンダリングをサーバーからクライアントへRSCが送るシリアライズされたデータです。 JSONとは異なる独自フォーマットで、React要素ツリーとClient Componentへの参照を含みます。

RSC Payloadのフォーマット例(概念的な表現)
// Server Componentがレンダリングした結果のイメージ
// 実際のフォーマットはストリーミング可能なテキストプロトコル

// Server Componentはこのようなツリーをシリアライズして送る:
{
  type: "article",           // ← HTMLタグはそのまま含まれる
  props: { className: "..." },
  children: [
    { type: "h1", props: {}, children: ["ブログ記事タイトル"] },
    { type: "p", props: {}, children: ["記事の内容..."] },
    // Client Componentは「参照」として送られる
    {
      type: "CLIENT_REFERENCE",  // ← "use client"のコンポーネント
      id: "./LikeButton.tsx#LikeButton",  // モジュールパス
      props: {
        postId: "abc123",       // シリアライズ可能なpropsのみ
        initialLikes: 42
      }
    }
  ]
}

// ブラウザ側:
// - HTMLタグ部分はDOMに直接反映
// - CLIENT_REFERENCEの部分はLikeButton.jsをロードしてHydrate
RSC Payloadで送れるprops・送れないprops
✅ シリアライズ可能(送れる)
  • string, number, boolean
  • null, undefined
  • 配列、プレーンオブジェクト
  • Date, URL, RegExp
  • Map, Set, BigInt
  • Uint8Array等のTypedArray
  • Promise(Server Actions用)
❌ シリアライズ不可(送れない)
  • 関数(通常の関数)
  • クラスインスタンス(カスタムクラス)
  • Symbol
  • 循環参照を持つオブジェクト
  • WeakMap, WeakSet, WeakRef
  • React要素(JSX)※制限あり

⚠️ 関数をpropsとして渡せない理由

Server ComponentからClient Componentに関数をpropsとして渡すことはできません。 関数はシリアライズできないためです(コードをネットワーク越しに送れない)。 ただしServer Actions"use server"でマークした関数)は例外で、 サーバー側の関数への参照をpropsとして渡せます。

🔍 Server ComponentとClient Component:できること・できないこと

機能比較表
Server Component
Client Component
async/await
✅ 直接使える
❌(useEffectで代替)
DBアクセス
✅ 直接アクセス可
❌(API経由のみ)
fs(ファイルシステム)
✅ Node.js APIが使える
useState / useReducer
❌ 使用不可
useEffect
❌ 使用不可
イベントハンドラ
❌ 使用不可
ブラウザAPI(localStorage等)
❌ 存在しない
Context(useContext)
❌ 使用不可
JSバンドルへの包含
✅ 含まれない
含まれる
Server ComponentとClient Componentの組み合わせパターン
// ✅ パターン1:Server ComponentがClient Componentをchildrenとして受け取る
// Server Component
async function Layout({ children }) {
  const user = await getUser(); // DBアクセス
  return (
    <html>
      <body>
        <nav>{user.name}</nav>
        {children} {/* Client Componentがここに入っても OK */}
      </body>
    </html>
  );
}

// ✅ パターン2:Server ComponentをClient Componentのchildrenとして渡す
// page.tsx(Server Component)
async function Page() {
  const data = await fetchData();
  return (
    <ClientWrapper>
      {/* Server Componentのレンダリング結果をchildrenとして渡す */}
      <ServerContent data={data} />
    </ClientWrapper>
  );
}

// ❌ パターン3(NG):Client ComponentがServer Componentを直接import
"use client"
import ServerComponent from './ServerComponent'; // ❌
// "use client"の境界内にServerComponentを引き込んでしまう
// ServerComponentはClient Componentになってしまう

🚀 Next.js App Router:RSCの実装例

Next.js App RouterはRSCをサポートした最初の主要フレームワークです。 App RouterではデフォルトがServer Componentで、 "use client"を書いた場合のみClient Componentになります。

App RouterでのRSCの使い方
// app/blog/[id]/page.tsx
// デフォルトでServer Component

import { notFound } from 'next/navigation';
import { LikeButton } from '@/components/LikeButton';
import { CommentSection } from '@/components/CommentSection';

// asyncコンポーネント(Server Componentのみ可)
export default async function BlogPost({
  params: { id }
}: {
  params: { id: string }
}) {
  // ✅ DB直接アクセス(APIルート不要)
  const post = await prisma.post.findUnique({
    where: { id },
    include: { author: true, tags: true }
  });
  
  if (!post) notFound();
  
  return (
    <article className="max-w-3xl mx-auto">
      <h1>{post.title}</h1>
      <p className="text-gray-500">by {post.author.name}</p>
      
      {/* Server ComponentでMarkdownをレンダリング */}
      {/* markdownパーサーはバンドルに含まれない! */}
      <MarkdownContent content={post.content} />
      
      {/* Client Component - いいね機能(インタラクション必要)*/}
      <LikeButton postId={post.id} initialLikes={post._count.likes} />
      
      {/* Client Component - コメントセクション */}
      <CommentSection postId={post.id} />
    </article>
  );
}

// app/components/LikeButton.tsx
"use client"

import { useState, useTransition } from 'react';
import { incrementLike } from '@/app/actions'; // Server Action

export function LikeButton({ postId, initialLikes }) {
  const [likes, setLikes] = useState(initialLikes);
  const [isPending, startTransition] = useTransition();
  
  return (
    <button
      onClick={() => startTransition(async () => {
        setLikes(l => l + 1);
        await incrementLike(postId); // Server Action呼び出し
      })}
      disabled={isPending}
    >
      ❤️ {likes}
    </button>
  );
}
App RouterのRSCレンダリングフロー
1

リクエスト受信

Next.jsサーバーがリクエストを受け取る

2

Server Componentsのレンダリング

asyncコンポーネントが実行され、DBアクセスや外部APIコールが行われる

3

RSC Payload + HTML生成

SSR用のHTML文字列とRSC PayloadをStreamingで送信開始

4

ブラウザでのHydration

HTMLが表示された後、RSC PayloadをもとにClient ComponentsをHydrate

5

インタラクション開始

Client ComponentsのJSがロードされ、onClickなどが有効になる

Server Actions:"use server"
// app/actions.ts
"use server" // このファイルの関数はすべてServer Action

import { revalidatePath } from 'next/cache';

// Server ActionはClient ComponentからRPC的に呼び出せる
export async function incrementLike(postId: string) {
  await prisma.post.update({
    where: { id: postId },
    data: { likesCount: { increment: 1 } }
  });
  
  // キャッシュの再検証(ページを更新)
  revalidatePath(`/blog/${postId}`);
}

// Client Componentから呼び出す
"use client"
import { incrementLike } from '@/app/actions';

function LikeButton({ postId }) {
  return (
    <button
      onClick={async () => {
        // APIルートを作らなくてもサーバー関数を直接呼べる
        await incrementLike(postId);
      }}
    >
      いいね
    </button>
  );
}

📌 まとめ

  • ✓ RSCの目的はJavaScriptバンドルサイズの削減。サーバー専用コンポーネントのJSはブラウザに届かない
  • ✓ SSRはHTMLを高速に届けるためのもの。RSCはバンドルを削減するためのもの(目的が異なる)
  • ✓ "use client"はコンポーネントの宣言ではなく、サーバー/クライアントモジュールグラフの境界線
  • ✓ RSC PayloadはServer ComponentのレンダリングをシリアライズしてクライアントへStreamingで送るデータ
  • ✓ RSCからClient Componentへはシリアライズできるpropsのみ渡せる(関数は不可)
  • ✓ Client ComponentをchildrenとしてServer Componentに渡すパターンで「孤島」を作れる
  • ✓ Server ActionsはClient ComponentからServer関数をRPC的に呼び出す仕組み
  • ✓ Next.js App RouterはRSCをデフォルトにし、SSRとRSCを統合した現時点での主要な実装

関連記事