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)だけがブラウザに送られます。
JSバンドルに含まれない
JSバンドルに含まれる
シリアライズされたUIデータ
🗺️ RSCとSSRは何が違うのか
RSCとSSRは「サーバーで処理する」という点で似ていますが、目的も仕組みも根本的に異なります。 この2つの混同がRSCを理解する最大の障壁です。
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である」という宣言ではなく、
「サーバーとクライアントのモジュールグラフの境界線」を意味します。
// 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はバンドルに含まれます。
// 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への参照を含みます。
// 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 - 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:できること・できないこと
// ✅ パターン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/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>
);
} リクエスト受信
Next.jsサーバーがリクエストを受け取る
Server Componentsのレンダリング
asyncコンポーネントが実行され、DBアクセスや外部APIコールが行われる
RSC Payload + HTML生成
SSR用のHTML文字列とRSC PayloadをStreamingで送信開始
ブラウザでのHydration
HTMLが表示された後、RSC PayloadをもとにClient ComponentsをHydrate
インタラクション開始
Client ComponentsのJSがロードされ、onClickなどが有効になる
// 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を統合した現時点での主要な実装