こんにちは。フロントエンドチームのシニアアーキテクトだ。
今日は、君たちが日々の開発で頭を悩ませているかもしれない「React Server Components(RSC)とPropsの交差点」、特にRSCをProps(childrenやカスタムスロット)としてコンポーネントに渡すパターンについて話をしよう。
「Server Componentsなんだから、サーバー側で完結させればいいじゃん」と思っていないか?
実は、レイアウトの骨組み(Shell)をクライアントコンポーネント(RCC)で保持しつつ、その中身(Content)にRSCを流し込むというこのアプローチは、Next.jsなどのApp Routerを使ったモダンな開発において最高にエレガントで、かつパフォーマンス上の恩恵を最大限に引き出すための必須テクニックなんだ。
さあ、裏側の仕組みから実務での実践的なコードまで、しっかりと頭に叩い込んでいってくれ。
—
なぜ「RSCをPropsとして渡す」必要があるのか?
まず、大前提を整理しよう。React 18以降のServer Componentsは、サーバーサイドでレンダリングされ、JSONライクな直列化されたストリーム(React Flightフォーマット)としてクライアントに送られてくる。
ここで中級者がよくやりがちなミスがこれだ。
// ❌ やりがちなアンチパターン
// Client Componentの中で直接 Server Componentをインポートして呼び出す
‘use client’;
import { ExpensiveServerComponent } from ‘./ExpensiveServerComponent’;
export function ClientLayout() {
const [isOpen, setIsOpen] = useState(false);
return (
{/ RCCの中にRSCを直接置くと、境界線が曖昧になり、バンドルサイズや再レンダリングの罠にハマる /}
);
}
この書き方だと、何が起きるか?
クライアントコンポーネント(`ClientLayout`)の子孫としてRSCを配置すると、WebpackやTurbopackなどのバンドラーやReactのライフサイクルにおいて、意図しないクライアント側の境界(Client Boundary)の汚染が起き、最悪の場合、本来サーバー側で処理されるべき重い処理や機密情報がクライアント側に引っ張られる原因になる。
正解:親(サーバー)で組み立てて、Propsとして流し込む
RSCを親のServer Componentでレンダリングし、それをProps(`children`やカスタムスロット)としてClient Componentに渡す。この構成をとることで、Reactのレンダリングパイプラインにおいて「サーバー側で生成されたHTML/ツリー構造」を、クライアント側は単なる「ブラックボックスのUI部品(不透明なオブジェクト)」として安全に受け取って描画できるようになる。
これが、今日のテーマの核心だ。
—
ブラウザの裏側で何が起きているのか?(React Flightの視点)
「PropsとしてJSXを渡すって、結局クライアント側で再計算されてるんじゃないの?」
そう疑問に思った君、非常に鋭い。
裏側で何が起きているかというと、サーバー側でRSC(例:DBから直接データを引くコンポーネント)が実行され、そのレンダリング結果のツリー構造(React Node)が確定する。その確定したツリーが、HTTPレスポンスに乗せてクライアントへストリーミングされるんだ。
クライアント側のClient Componentは、その受け取った「すでに完成されたJSXの断片」を、単に指定されたスロット(`children`など)にパズルのピースのように嵌め込んでいるに過ぎない。
つまり、Client Componentの状態(useStateなど)が変化して再レンダリングが走ったとしても、Propsとして渡されたRSCの再フェッチや再レンダリングは発生しない。
これが、パフォーマンス上有利になる決定的な理由だ。RCCの状態管理の変更に、RSCのライフサイクルが巻き込まれずに済む。
—
【実務コード例】スロットパターンを用いた実装
百聞は一見にしかずだ。実務でそのまま使える、タブ切り替えUIのサンプルコードを見てみよう。
タブの状態管理はクライアント側で行うが、各タブの中身(コンテンツ)はサーバー側でデータフェッチを行うRSCとして構成し、それをProps(カスタムスロット)経由で渡すパターンだ。
1. タブのシェル(Client Component)
まずは、クライアント側でインタラクティブな状態(どのタブが選択されているか)を管理するコンポーネントだ。
// components/TabShell.tsx
‘use client’;
import { useState, ReactNode } from ‘react’;
type TabShellProps = {
// デフォルトのchildrenスロット
children: ReactNode;
// カスタムスロットとして複数のRSCを受け取る
analyticsSlot: ReactNode;
settingsSlot: ReactNode;
};
export function TabShell({ children, analyticsSlot, settingsSlot }: TabShellProps) {
const [activeTab, setActiveTab] = useState<'home' | 'analytics' | 'settings'>(‘home’);
return (
{/ タブのコンテンツ切り替え /}
{activeTab === ‘analytics’ && analyticsSlot}
{activeTab === ‘settings’ && settingsSlot}
);
}
2. 重いデータフェッチを行うRSC群
次に、サーバーサイドでデータベースや外部APIを叩く、純粋なServer Componentsを用意する。
// app/components/AnalyticsContent.tsx
// ‘use client’ を書かないため、これはデフォルトで Server Component
export async function AnalyticsContent() {
// サーバー側で重いDBクエリを実行していると仮定
await new Promise((resolve) => setTimeout(resolve, 1000));
const stats = { totalUsers: 14200, revenue: ‘¥3,420,000’ };
return (
サーバーサイド・アナリティクス
総ユーザー数: {stats.totalUsers.toLocaleString()} 人
総売上: {stats.revenue}
);
}
// app/components/SettingsContent.tsx
export async function SettingsContent() {
return (
サーバーサイド・設定情報
組織ID: org_992819283 (セキュアなサーバー環境で取得)
);
}
3. 親ページ(Server Component)で合成する
そして、これらすべてのピースを大元のServer Component(Page)で組み立てて、Client Componentに流し込む。
// app/page.tsx
import { TabShell } from ‘./components/TabShell’;
import { AnalyticsContent } from ‘./components/AnalyticsContent’;
import { SettingsContent } from ‘./components/SettingsContent’;
// これ自体が Server Component
export default async function Page() {
return (
RSC Props パターン デモ
{/
親(サーバー)側でRSCをインスタンス化し、
childrenやカスタムスロットとしてクライアントコンポーネントに渡す
/}
settingsSlot={
>
{/ childrenとしてのデフォルトRSC /}
ホーム画面コンテンツ
ここは通常のサーバーレンダリングされた静的コンテンツです。
);
}
この構成の美しいところを分かってもらえただろうか?
`TabShell` はクライアントのクリック状態(`activeTab`)に応じてどのスロットを表示するかを制御しているが、それぞれのスロットの中身(`AnalyticsContent` や `SettingsContent`)は、ページにアクセスした瞬間にサーバー側ですでにレンダリングが完了している。
ユーザーがタブを切り替えた時、クライアント側で余計なAPIリクエストが発生することなく、瞬時にプレコンパイルされたHTML断片が切り替わる。これが爆速のUXを生み出す秘密だ。
—
現場で役立つ実践的Tipsと注意点
最後に、シニアとしてこのパターンを実務に導入する際に気をつけるべき「現場の知見」をいくつか授けておこう。
1. TypeScriptの型定義は `ReactNode` を使え
PropsとしてRSCを受け取る側のコンポーネントでは、型を `ReactNode` にしておくのが定石だ。
import { ReactNode } from ‘react’;
type MyProps = {
customSlot: ReactNode;
}
これで、サーバー側から渡されるあらゆるJSX要素(Server Componentの実行結果を含む)を綺麗に受け取ることができる。
2. 「Prop Drilling(バケツリレー)」の亜種に気をつけろ
あまりにも多くのスロットを何階層も下の子孫コンポーネントへ渡そうとすると、コードがカオスになる。スロットパターンは、「インタラクティブなUIシェル」と「データ駆動型のコンテンツ」の境界線、つまり1〜2階層程度の浅いレイアウト構造で使うのが最も効果的だ。
3. サスペンス(Suspense)の境界をどこに置くか
もし渡すRSCの中にデータフェッチが含まれており、描画に時間がかかるものが混ざっているなら、親側またはスロットを受け取る側で `
—
まとめ
RSCをPropsとして渡すパターンは、単なるテクニックではない。「インタラクティブなUI(クライアント)」と「データと重い処理(サーバー)」の責務を綺麗に分離するためのアーキテクチャ上の強力な武器だ。
公式ドキュメントの端っこにサラッと書いてあるような内容だが、現場の複雑なプロダクトでこれを使いこなせるようになると、パフォーマンスのボトルネックの大部分を華麗に回避できるようになる。
さて、理屈はここまでだ。お前のエディタを開いて、さっそくこのパターンを既存のコードベースに適用してみてくれ。壁にぶぶんだら、いつでも俺のところへ質問に来るといい。頼もしい成果を楽しみにしているぞ。

コメント