React Server Components Flightプロトコル: ストリーミングワイヤーフォーマット、シリアライゼーション、選択的ハイドレーション

目次(15 項目)
React Server Components (RSC)は、Reactアプリケーション開発のメンタルモデルを根本的に変え、レンダリングとデータフェッチの大部分をサーバーに移行させます。このパラダイムでは、新しい通信プロトコルであるReact Flightが導入され、サーバーがUIの更新をクライアントにどのように送信するかが規定されます。このプロトコル、そのシリアライゼーションメカニズム、およびその後の選択的ハイドレーションプロセスを理解することは、RSCベースのアプリケーションを最適化するために不可欠です。
React Flightプロトコル:ストリーミングワイヤーフォーマット
React Flightプロトコルは、React要素ツリーを効率的に転送するために設計された、カスタムのストリーミングJSONライクなフォーマットです。静的なドキュメントである従来のHTMLとは異なり、Flightは動的な命令ベースのストリームです。これは生のHTMLではなく、React要素のシリアライズされた表現であり、その型、プロップ、子要素、およびクライアントがUIを再構築するための命令が含まれます。
核となる原則は、サーバーがコンポーネントをレンダリングし、「チャンク」のストリームをクライアントに送信することです。各チャンクには、新しいUI要素、既存の要素への更新、またはクライアントサイドコードをロードするための命令が含まれます。このストリーミングの性質は、コンポーネントツリー全体が解決される前にクライアントがUIの一部をレンダリングを開始できるため、体感パフォーマンスにとって非常に重要です。
ワイヤーフォーマットの構造
Flightワイヤーフォーマットは、JSON配列のシーケンスであり、それぞれが特定の命令またはデータペイロードを表します。各配列の最初の要素は、命令のタイプを示す数値タグです。
単純なRSCを考えてみましょう。
// app/page.tsx (Server Component)
import ClientComponent from './ClientComponent';
export default function Page() {
const data = fetchData(); // Server-side data fetching
return (
<div>
<h1>Welcome to RSC</h1>
<p>{data}</p>
<ClientComponent />
</div>
);
}
// app/ClientComponent.tsx (Client Component)
'use client';
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Client Count: {count}
</button>
);
}
Pageがサーバーでレンダリングされると、Flightプロトコルは次のようなストリームを出力する可能性があります(明確にするために簡略化されています)。
0:["$","div",null,{"children":[["$","h1",null,{"children":"Welcome to RSC"}],["$","p",null,{"children":"Server Data"}],["$","@1",null,{}]]}]
1:I{"id":"./app/ClientComponent.tsx","chunks":["app_ClientComponent_tsx"],"name":"default"}
一般的な命令タイプを分解してみましょう。
0: [type, key, ref, props]- React要素を表します。
type: 文字列("div"、"p"のような組み込み要素の場合)またはクライアントコンポーネントモジュールへの参照(例:"$@1")です。key: Reactkeyプロップ。ref: Reactrefプロップ。props: 要素のプロップを含むオブジェクト。
1: I{id, chunks, name}- クライアントコンポーネントモジュールをインポートする命令。
id: モジュール識別子(例:クライアントコンポーネントファイルへのパス)。chunks: このモジュールをロードするためのチャンク名の配列。name: モジュールからエクスポートされた名前(例:"default")。
2: [id, props]- クライアントコンポーネントインスタンスを表します。
idは以前にインポートされたモジュールを参照します。
- クライアントコンポーネントインスタンスを表します。
3: [id, value]- シリアライズされた値(例:コンポーネントに解決されるプロミス)を表します。
4: [id, error]- エラーバウンダリのフォールバックを表します。
5: [id, promise_id]- 解決する必要があるプロミスを表します。クライアントは、これを解決するために
promise_idを含む別のチャンクを待ちます。
- 解決する必要があるプロミスを表します。クライアントは、これを解決するために
クライアントサイドのReactランタイムはこのストリームを解析し、仮想DOMを再構築してレンダリングします。I命令が検出されると、クライアントサイドのバンドラー(例:Webpack、Turbopack)は、クライアントコンポーネントに対応するJavaScriptチャンクをロードするように指示されます。
プロップのシリアライゼーション
すべてのJavaScript型がワイヤー経由で直接シリアライズできるわけではありません。関数、イベントハンドラー、およびプレーンなデータ構造ではない複雑なオブジェクトは、サーバーコンポーネントからクライアントコンポーネントに直接渡すことはできません。
サーバーコンポーネントからクライアントコンポーネントへのプロップ: シリアライズ可能なデータ(プリミティブ、プレーンオブジェクト、配列)のみを渡すことができます。関数、JSX要素、またはクラスインスタンスはシリアライズできません。
// ServerComponent.tsx
import ClientComponent from './ClientComponent';
export default function ServerComponent() {
const serverData = { message: 'Hello from server' };
// This is fine: serverData is serializable
return <ClientComponent data={serverData} />;
}
// ClientComponent.tsx
'use client';
export default function ClientComponent({ data }) {
// data.message will be 'Hello from server'
return <p>{data.message}</p>;
}
クライアントコンポーネントからサーバーコンポーネントへのプロップ(children経由): これは一般的なパターンです。サーバーコンポーネントはクライアントコンポーネントをレンダリングし、サーバーコンポーネントを子として渡すことができます。サーバーコンポーネントの子はサーバーでレンダリングされ、そのシリアライズされた出力がクライアントコンポーネントに渡されます。
// ServerComponent.tsx
import ClientWrapper from './ClientWrapper';
export default function ServerComponent() {
// Server-rendered content passed as children to a Client Component
return (
<ClientWrapper>
<h2>This is a Server Component child</h2>
<p>It was rendered on the server.</p>
</ClientWrapper>
);
}
// ClientWrapper.tsx
'use client';
import { useState } from 'react';
export default function ClientWrapper({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Click me: {count}</button>
{children} {/* Server-rendered content */}
</div>
);
}
このシナリオでは、childrenは関数やJSXオブジェクトとしてシリアライズされません。代わりに、<h2>と<p>要素はサーバーでFlightワイヤーフォーマット表現にレンダリングされ、そのシリアライズされた表現がClientWrapperに渡されます。クライアントサイドのReactランタイムは、ClientWrapperのプロップの一部として、このシリアライズされたchildrenを処理します。
クライアントコンポーネントとサーバーコンポーネントのパーティショニング
クライアントコンポーネントとサーバーコンポーネントの区別は、ビルドステップで強制されます。バンドラー(Next.jsのWebpackやTurbopackなど)は、'use client'ディレクティブを分析します。
- サーバーコンポーネント: クライアント用にバンドルされることはありません。そのコードはサーバー上にのみ存在します。サーバーコンポーネント内のインポートも、明示的にクライアントコンポーネントをインポートしない限り、サーバーサイドであると見なされます。
- クライアントコンポーネント: クライアント用にバンドルされます。サーバーコンポーネントがクライアントコンポーネントをインポートすると、バンドラーはそのクライアントコンポーネント用に個別のJavaScriptチャンクを作成します。サーバーコンポーネントは、Flightプロトコルを介してこのチャンクへの参照のみを送信します。
このパーティショニングは、クライアントサイドのバンドルサイズを削減し、機密性の高いロジックや資格情報をクライアントに公開することなくサーバーサイドのデータフェッチを可能にするために重要です。
ストリーミングHTMLシェルとSuspense
RSCは、ストリーミングのためにReactのSuspenseメカニズムを活用しています。サーバーコンポーネントが非同期操作(例:データフェッチ)を実行し、まだ解決されていない場合、Reactはそのツリーの一部をレンダリングを中断できます。
すべてのデータが解決されるのを待つ代わりに、サーバーは、解決済みのUI部分と、中断されたコンテンツのプレースホルダー(<template>タグや<div>属性を持つdata-rsc-idなど)を含む初期のHTML「シェル」を送信できます。
<!-- Initial HTML Shell -->
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<div id="__next">
<h1>Welcome to RSC</h1>
<!-- Placeholder for suspended content -->
<div id="rsc-123"></div>
</div>
<script src="/_next/static/chunks/main.js"></script>
</body>
</html>
中断されたデータがサーバーで解決されると、Reactは追加のFlightプロトコルチャンクを送信します。これらのチャンクには、中断された部分の実際のUIが含まれており、クライアントサイドのReactランタイムはこれを使用してプレースホルダーを置き換えます。これにより、Time To First Byte (TTFB) と First Contentful Paint (FCP) が高速化されます。
// Later Flight chunk resolving rsc-123
3:["rsc-123",["$","p",null,{"children":"Resolved Server Data"}]]
このメカニズムは、ReactDOMServer.renderToReadableStream(Node.js環境の場合)またはrenderToPipeableStreamによって提供されます。これらのAPIは、最初にHTMLシェルを出力し、次にFlightプロトコルチャンクを含むスクリプトタグを出力し続けるストリームを返します。
選択的ハイドレーション
ハイドレーションとは、クライアントサイドのReactがサーバーでレンダリングされたHTMLを引き継ぎ、イベントリスナーをアタッチしてUIをインタラクティブにするプロセスです。従来のReactでは、アプリケーションツリー全体が一度にハイドレートされていました。大規模で重要でないコンポーネントのハイドレーションが遅い場合、ページ全体のインタラクティブ性がブロックされていました。
React 18で導入された選択的ハイドレーションは、ハイドレーションに優先順位を付けることでこの問題に対処します。クライアントサイドのReactランタイムが起動しても、すぐにすべてをハイドレートするわけではありません。代わりに、ユーザーのインタラクションを監視します。
- 初期ハイドレーション: Reactはルートからハイドレーションを開始しますが、
<Suspense>境界またはまだロード中のコンポーネントに遭遇すると一時停止できます。 - ユーザーインタラクション: ユーザーがDOMの特定の部分(例:ボタンをクリック)とインタラクションすると、Reactはそのインタラクションを担当するコンポーネントとその祖先のハイドレーションを優先します。これにより、重要なインタラクティブ要素がはるかに速く応答するようになります。
- バックグラウンドハイドレーション: 重要でないコンポーネントは、ユーザーインタラクションをブロックすることなく、バックグラウンドでハイドレーションを続行します。
この優先順位付けは、Reactがツリーのどの部分が「ブロックされている」(例:チャンクのロードを待っている、プロミスの解決を待っている)か、どの部分が「インタラクティブ」であるかを内部的に追跡することで実現されます。イベントが発生すると、Reactはイベントターゲットとそれを処理する必要があるコンポーネントの最も低い共通祖先を決定し、そのパスのハイドレーションを優先します。
例の流れ:
- サーバーは、ボタンと大きくロードの遅いコンポーネントを含むHTMLシェルを送信します。
- クライアントはHTMLを受信します。ボタンは表示されますが、インタラクティブではありません。
- ユーザーがボタンをクリックします。
- Reactはクリックを検出し、ボタンのコンポーネントを識別し、そのコンポーネントとその親のハイドレーションを優先します。
- ボタンはほぼ即座にインタラクティブになります。
- その間、ロードの遅いコンポーネントは、ボタンをブロックすることなく、バックグラウンドでデータのフェッチとハイドレーションを続行します。
アーキテクチャとトレードオフ
| 機能 | 従来のSSR (例: Next.js Pages) | React Server Components (RSC) |
|---|---|---|
| データフェッチ | クライアントでのgetServerSideProps、getStaticProps、useEffect | サーバーコンポーネントでの直接のasync/await |
| バンドルサイズ | クライアントバンドルにはすべてのコンポーネントコードが含まれる | クライアントバンドルにはクライアントコンポーネントのみが含まれる |
| ハイドレーション | 全か無か (React 17)、選択的 (React 18) | ストリーミングによって強化された選択的ハイドレーション |
| ネットワークペイロード | HTML + JSONデータ (props用) | HTMLシェル + Flightプロトコルストリーム |
| インタラクティブ性 | 完全なハイドレーション後 | プログレッシブ、ユーザーインタラクションによって優先順位付け |
| 複雑性 | 明確なクライアント/サーバー分離 | クライアント/サーバーの融合モデル、新しいメンタルモデル |
| キャッシング | HTMLキャッシング、CDN | HTMLキャッシング、RSCペイロードキャッシング (実験的) |
本番環境での注意点とトラブルシューティング
-
RSCでの「Functions are not valid as a React child」/「Objects are not valid as a React child」エラー:
- 問題: 関数、JSX要素、または非シリアライズ可能なオブジェクトを、プロップとしてサーバーコンポーネントからクライアントコンポーネントに直接渡そうとしている。Flightプロトコルはこれらをシリアライズできません。
- 解決策:
- JSXを渡す場合は、サーバーでレンダリングし、その結果(シリアライズされたFlightペイロード)を
childrenとしてクライアントコンポーネントに渡します。 - 関数を渡す場合は、クライアントコンポーネント自体の中で定義するか、シリアライズ可能なデータのみを渡し、クライアントコンポーネントにその関数を構築させます。
- サーバーからクライアントコンポーネントに渡されるすべてのプロップがJSONシリアライズ可能であることを確認します。
- JSXを渡す場合は、サーバーでレンダリングし、その結果(シリアライズされたFlightペイロード)を
- 例:
tsx
// ❌ Bad: Passing a function from Server to Client // ServerComponent.tsx function handleClick() { console.log('clicked'); } <ClientButton onClick={handleClick} /> // ✅ Good: Function defined in Client Component // ClientButton.tsx 'use client'; export default function ClientButton() { function handleClick() { console.log('clicked'); } return <button onClick={handleClick}>Click</button>; }
-
クライアントコンポーネントのコードが誤ってサーバーで実行される:
- 問題: ファイルの先頭にある
'use client'ディレクティブを忘れている。バンドラーはそれをサーバーコンポーネントとして扱い、クライアント専用API(例:window、useState)を使用している場合にエラーが発生します。 - 解決策: クライアントコンポーネントとして意図されたすべてのファイルの先頭に、必ず
'use client'を含めます。 - トラブルシューティング: クライアント専用であると想定していたファイルから発生するサーバーサイドエラーのスタックトレースを確認します。
- 問題: ファイルの先頭にある
-
推移的な依存関係による大規模なクライアントコンポーネントバンドル:
- 問題: 小さなクライアントコンポーネントが大きなライブラリをインポートし、そのライブラリ全体がクライアントバンドルに引き込まれる。
- 解決策:
- Next.js Bundle Analyzerなどのツールを使用して、クライアントバンドルサイズを分析します。
- 可能な限り多くのロジックをサーバーコンポーネントに移動するようにリファクタリングします。
- 重要度の低いクライアントコンポーネントには、
import()とReact.lazyを使用して動的インポートを行い、遅延ロードします。 - ライブラリがツリーシェイク可能であることを確認します。
- 例:
tsx
// ClientComponent.tsx 'use client'; import { SomeHeavyUtility } from 'heavy-library'; // This pulls heavy-library into client bundle // Consider if SomeHeavyUtility can be used in a Server Component // or if this Client Component can be dynamically imported.
-
ストリーミングにもかかわらず初期ページロードが遅い:
- 問題: ルートレイアウトまたは重要なコンポーネントが長期間中断され、初期HTMLシェルが遅延する。
- 解決策:
- ルートで最も遅いデータフェッチまたはコンポーネントを特定します。
- 重要でないデータフェッチは、
<Suspense>境界でラップしてコンポーネントツリーのより深い位置に移動し、初期シェルをブロックしないようにします。 - サーバーサイドのデータフェッチが最適化されていることを確認します(例:データベースクエリ、API呼び出し)。
- Next.jsで
loading.tsxを使用して、即座のフォールバックUIを提供します。
-
ハイドレーション中のHTMLの不一致:
- 問題: サーバーでレンダリングされたHTMLが、クライアントサイドのReactがレンダリングを期待するものと異なり、ハイドレーションエラー(
Warning: Prop 'className' did not match.)が発生する。これは、動的なコンテンツや、誤ってサーバーで実行されるクライアント専用ロジックでよく発生します。 - 解決策:
- クライアント専用ロジック(例:
windowアクセス、localStorage)がtypeof window !== 'undefined'で保護されているか、useEffectフック内に配置されていることを確認します。 - サーバーとクライアントの両方でレンダリングされるコンポーネントで、慎重な同期なしに
Math.random()やDate.now()を直接使用することは避けてください。 - サードパーティライブラリを使用する場合は、SSR/RSCと互換性があることを確認します。場合によっては、クライアント専用ライブラリを
suppressHydrationWarning(最終手段として)または動的インポートを持つコンポーネントでラップすると役立つことがあります。
- クライアント専用ロジック(例:
- 問題: サーバーでレンダリングされたHTMLが、クライアントサイドのReactがレンダリングを期待するものと異なり、ハイドレーションエラー(
よくある質問
1. サーバーコンポーネントでuseStateやuseEffectを使用できますか?
いいえ。サーバーコンポーネントはステートレスであり、useState、useEffect、useRef、useContextのようなReactフックにアクセスできません。これらのフックは、ブラウザで状態と副作用を管理するためのクライアントサイドの構成要素です。これらを使用するコンポーネントはすべて'use client'でマークする必要があります。
2. サーバーコンポーネントとクライアントコンポーネント間でデータを共有するにはどうすればよいですか?
データは、シリアライズ可能(プリミティブ、プレーンオブジェクト、配列)である限り、プロップを介してサーバーコンポーネントからクライアントコンポーネントに渡すことができます。クライアントコンポーネントからサーバーコンポーネントにデータを渡すには、通常、サーバーアクションまたはAPIルートを使用する必要があります。サーバーアクションを使用すると、クライアントコンポーネントからサーバーサイド関数を直接呼び出すことができます。
3. サーバーコンポーネントがクライアント専用ライブラリ(例:windowを使用するもの)をインポートするとどうなりますか?
サーバーコンポーネントが、適切な保護なしにブラウザ固有のAPI(windowやdocumentなど)に依存するライブラリをインポートすると、サーバーサイドのランタイムエラーが発生します。バンドラーは、クライアントコンポーネントの一部でない限り、このコードをサーバービルドから自動的に除外しません。そのようなインポートは、'use client'とマークされたファイル内でのみ行われるか、Next.jsのnext/dynamicを使用している場合はssr: falseで動的にインポートされるようにする必要があります。
4. Flightプロトコルはストリーミング中のエラーをどのように処理しますか?
サーバーコンポーネントのレンダリング中にサーバーでエラーが発生した場合、ReactはFlightストリームでエラー命令(4: [id, error])を送信できます。これにより、クライアントはUIの affected 部分にエラーバウンダリのフォールバックを表示でき、アプリケーション全体がクラッシュするのを防ぎます。これは、<Suspense>境界がプロミスをキャッチする方法と似ています。
5. FlightプロトコルはNext.jsに固有のものですか?
いいえ、React FlightプロトコルはReact Server Componentsのコア部分であり、Next.jsに固有のものではありません。Next.jsは早期採用者であり、RSCを実装するための堅牢なフレームワークを提供しています。他のフレームワークやカスタムセットアップでも、理論的にはFlightプロトコルを実装してRSCを活用できますが、Next.jsはそれを実用的にするための統合ツール(バンドル、ルーティング、データフェッチ)を提供しています。
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

React 19 Actions実践ガイド: useActionState, useOptimistic & Server Actionの回復性
React 19 Actionsの実践的な使用法を解説する包括的なガイド。useActionState, useOptimistic, Server Actionの回復性を本番環境レベルのアーキテクチャとコード例で紹介します。
Read more
ReactのハイドレーションミスマッチとServer Componentsを理解する
ServerComponentsでのReactハイドレーションミスマッチのデバッグ方法を習得し、クライアントとサーバーのマークアップの違いを回避し、Next.jsでのハイドレーションエラーを解消しましょう。
Read more
React Server ComponentsとClient Components:徹底解説
React Server Components(RSC)とClient Componentsのアーキテクチャ上の違いを理解し、現代のWeb開発で最適なパフォーマンスとインタラクティブ性を実現するためにそれぞれをいつ使用すべきかを学びましょう。
Read more