Read OSS

サーバーレンダリング — Fizz、Flight、React Server Components

上級

前提知識

  • 記事1〜5(クライアントサイドReactの完全な理解)
  • HTTPストリーミングとチャンク転送エンコーディングの基本知識
  • React Server Componentsの概念(use client・use serverディレクティブ)に対する基本的な理解
  • バンドラー統合のセクションを読むためのwebpackまたは他のバンドラーの基礎知識

サーバーレンダリング — Fizz、Flight、React Server Components

Reactのサーバーサイド処理は、単純な renderToString から3つの独立したアーキテクチャへと進化してきました。Fizz はSuspenseに対応したHTMLストリーミングを担います。Flight Server はReact Server Componentsをストリーミングのワイヤープロトコルにシリアライズします。Flight Client はそのプロトコルをクライアント側でReact要素に戻します。これら3つが組み合わさることで、サーバー専用・クライアント専用のコンポーネントをフレームワークが境界を管理しながら動かすRSCモデルが実現しています。

このシリーズ最終回では、3つのアーキテクチャ全体を俯瞰しつつ、サーバー上でのhooksの動作の違い、Flightのシリアライゼーションプロトコル、そして 'use client''use server' ディレクティブをwebpackやturbopackがどう処理するかを掘り下げます。

3つのサーバーアーキテクチャを俯瞰する

graph TD
    subgraph "Server"
        Fizz["Fizz<br/>(react-server/ReactFizzServer)<br/>Renders components → HTML stream"]
        Flight["Flight Server<br/>(react-server/ReactFlightServer)<br/>Renders RSC → JSON-like stream"]
    end

    subgraph "Client"
        FlightClient["Flight Client<br/>(react-client/ReactFlightClient)<br/>Deserializes RSC stream → React elements"]
        Reconciler["Fiber Reconciler<br/>(react-reconciler)<br/>Reconciles elements → DOM"]
    end

    Fizz -->|"HTML stream"| Browser["Browser DOM"]
    Flight -->|"RSC payload"| FlightClient
    FlightClient -->|"React elements"| Reconciler
    Reconciler --> Browser

Fizz は、強力な機能を備えた従来型のSSRです。Reactツリーを HTMLストリームとしてレンダリングしますが、レガシーの renderToString と異なり、Suspense境界を正しく扱います。fallbackのHTMLを即座に出力しつつ、データが届いたタイミングで解決済みのコンテンツをストリーミングし、その切り替えを行うインラインの <script> タグも合わせて送出します。

Flight Server はHTMLを生成しません。Server Componentsをレンダリングし、その出力をストリーミングプロトコルにシリアライズします。このとき、client componentへの参照はサーバー上でレンダリングされず、不透明な参照としてそのまま扱われます。出力は型付きチャンクのシーケンスで、React要素・クライアント参照・シリアライズされたprops・Suspense境界などが含まれます。

Flight Client はこのストリームを受け取り、React要素を再構築します。バンドラーのマニフェストを使ってclient component参照を実際のコンポーネント関数に解決し、その結果を通常のFiber reconcilerに渡します。

Fizz — ストリーミングサーバーサイドレンダリング

FizzはFiber reconcilerとは独立した独自のワークループを持っています。コアは ReactFizzServer.js にあり、5,000行を超える完全なレンダリングエンジンです。

performWork 関数がFizzのレンダリングを駆動します。

export function performWork(request: Request): void {
  if (request.status === CLOSED || request.status === CLOSING) {
    return;
  }
  const prevContext = getActiveContext();
  const prevDispatcher = ReactSharedInternals.H;
  // ... install Fizz's hooks dispatcher, then render
}

Fizzは各ワークチャンク内でコンポーネントツリーを同期的に処理し、HTMLチャンクを順次生成していきます。Suspense境界に到達した際の戦略は2つあります。

  1. コンテンツが準備できている場合: そのままインラインでレンダリングし、境界のオーバーヘッドは発生しません
  2. コンテンツがサスペンドする場合: fallbackのHTMLをその場で出力して処理を継続します。データが解決されたタイミングで、完成したHTMLとfallbackを差し替えるインラインの <script> をストリーミングします

この順不同のストリーミングこそがFizzの強みです。ブラウザはfallbackを表示しながら即座に描画を始め、データが届くにつれてコンテンツが段階的に更新されていきます。

DOM向けのFizzエントリーポイントは ReactDOMFizzServerBrowser.js です。

function renderToReadableStream(
  children: ReactNodeList,
  options?: Options,
): Promise<ReactDOMServerReadableStream> {
  return new Promise((resolve, reject) => {
    // ... creates Fizz request, starts work, returns ReadableStream
  });
}

補足: FizzとFiber reconcilerは完全に独立したレンダリングエンジンです。Fizzはfiberを生成せず、記事3で解説したワークループも使いません。laneシステムも使用しません。独自のタスクモデル、独自のコンテキストスタック、独自のhooksディスパッチャーを持っています。両者が react-server パッケージを共有しているのは、どちらもコンポーネントのレンダリングが必要なためだけです。

サーバーサイドのhooks — 異なるディスパッチャー

記事4で学んだように、hooksはディスパッチャーパターンを通じて機能します。サーバー上では、hook APIのサブセットを実装した専用のディスパッチャーがインストールされます。

FizzのhooksはFiber reconcilerとは独立した独自のワークループを持っています。コアは ReactFizzHooks.js にあります。FlightのhooksはFiber reconcilerとは独立した独自のワークループを持っています。コアは ReactFlightHooks.js にあります。

Hook Fizz (SSR) Flight (RSC)
useState ✅ 利用可(初期状態のみ・更新なし) ❌ 利用不可
useReducer ✅ 利用可(初期状態のみ) ❌ 利用不可
useEffect ⏭️ 無効(effectはクライアント専用) ❌ 利用不可
useLayoutEffect ⏭️ 無効(devモードで警告あり) ❌ 利用不可
useRef ✅ 利用可({current}を返す) ❌ 利用不可
useMemo ✅ 利用可 ✅ 利用可
useCallback ✅ 利用可 ✅ 利用可
useContext ✅ 利用可 ✅ 利用可
useId ✅ 利用可(決定論的なIDを生成) ✅ 利用可
use ✅ 利用可(Promiseをawaitできる) ✅ 利用可

ReactServer.js のエントリーポイントは、サーバーコンテキストで使用できるhooksのみをエクスポートしています。useStateuseEffectuseReducer といったクライアント状態管理用のhooksは含まれていません。

export {
  Children, Activity, Fragment, Profiler, StrictMode,
  Suspense, ViewTransition,
  cloneElement, createElement, createRef, use,
  forwardRef, isValidElement, lazy, memo,
  cache, cacheSignal,
  useId, useCallback, useDebugValue, useMemo,
  version, captureOwnerStack,
};

この制約は package.jsonreact-server exportコンディションによって強制されています。バンドラーがサーバー上で react を解決すると、この制限されたAPIのみを公開したモジュールが返ります。

Flight Server — React Server Componentsのシリアライズ

Flight Server はServer Componentsをレンダリングし、ストリーミングプロトコルとして出力します。重要なのは、'use client' が付いたclient componentに遭遇したときの挙動です。

sequenceDiagram
    participant FS as Flight Server
    participant SC as Server Component
    participant CC as Client Component Reference

    FS->>SC: Render <ServerComponent />
    SC-->>FS: Returns <div><ClientComponent name="Alice" /></div>
    FS->>FS: Serialize <div> as React element
    FS->>CC: Encounter ClientComponent
    Note over FS,CC: Don't render! Serialize as reference
    FS->>FS: Emit: {type: "client-ref", id: "module123", props: {name: "Alice"}}

Server Componentsはサーバー上で完全にレンダリングされます。関数本体が実行され、useMemouseCallbackuseContext が呼ばれ、その出力がシリアライズされます。一方でclient componentsはレンダリングされません — それらはストリーム内の不透明な参照として扱われ、propsも一緒にシリアライズされます(propsはシリアライズ可能である必要があります)。

ワイヤープロトコルは改行区切りのチャンク列で構成されており、各チャンクには型タグとIDが付与されています。チャンクはIDで相互参照し合いながら、インクリメンタルにツリー構造を構築します。Suspense境界は自然なチャンク境界となります。Server Componentがサスペンドすると、Flight Serverは「ペンディング」チャンクを出力し、後から解決済みのコンテンツで置き換えます。

Server ComponentからClient ComponentへのpropsはReactのシリアライゼーション形式を通じてシリアライズ可能でなければなりません。JSONのプリミティブ・React要素・クライアント参照・サーバー参照・Promiseなどの構造化型がサポートされています。Server Actionsを除く関数は、この境界をまたいでpropsとして渡すことができません。

Flight Client — RSCストリームのデシリアライズ

Flight Client はストリーミングプロトコルを受け取り、React要素を再構築します。到着したチャンクを順次処理しながら参照を解決し、コンポーネントツリーを組み立てていきます。

flowchart TD
    Stream["RSC Stream<br/>(chunks arriving over time)"]
    Parse["Parse chunk type + ID"]
    
    Parse -->|"Element chunk"| RE["Create React element"]
    Parse -->|"Client reference"| CR["Resolve via bundler manifest<br/>requireModule(moduleId)"]
    Parse -->|"Pending chunk"| SP["Create Suspense-wrapped promise"]
    Parse -->|"Resolved chunk"| RP["Resolve pending promise<br/>Trigger re-render"]

    RE --> Tree["React Element Tree"]
    CR --> Tree
    SP --> Tree
    RP --> Tree

    Tree --> Reconciler["Feed to Fiber Reconciler"]

Flight Clientがclient referenceチャンクに遭遇すると、バンドラーのモジュールマニフェストを使って実際のコンポーネント関数へと解決します。ここでバンドラー統合が核心的な役割を担います — サーバーとクライアントがモジュールIDについて合意していなければなりません。

ペンディングチャンクはSuspenseと統合されています。Flight Clientが未解決のPromiseを参照するチャンクに遭遇すると、reconcilerがサスペンドできるthenableを生成します。その後Flight Serverが解決済みチャンクをストリーミングすると、Promiseが解決され、再レンダリングが走って新しいコンテンツが反映されます。

Flight Clientの処理結果は通常のReact要素ツリーです — ローカルのJSXから生成したものと区別がつきません。このツリーはFiber reconciler(記事3で解説した同じワークループ)に直接渡され、現在のDOMと照合されます。

バンドラー統合 — webpackとturbopack

RSCモデルは深いバンドラー統合を必要とします。'use client''use server' ディレクティブは、バンドラープラグインによってFlightプロトコルがシリアライズできる参照オブジェクトに変換されます。

ReactはバンドラーごとのパッケージをRSCモデルのために提供しています。

  • react-server-dom-webpack — webpack向け
  • react-server-dom-turbopack — turbopack(Next.js)向け
  • react-server-dom-parcel — Parcel向け
  • react-server-dom-esm — ネイティブESM向け

各パッケージにはサーバー用とクライアント用のエントリーがあります。webpackのサーバーエントリー はFlight Serverをwebpackのモジュールリゾルバーと接続し、クライアントエントリー はFlight Clientをwebpackのチャンクローダーとつなぎます。

graph TD
    subgraph "Build Time"
        UC["'use client' directive"] --> BP["Bundler Plugin"]
        US["'use server' directive"] --> BP
        BP --> CM["Client Manifest<br/>(moduleId → chunk mapping)"]
        BP --> SM["Server Manifest<br/>(actionId → handler mapping)"]
    end

    subgraph "Runtime (Server)"
        FSR["Flight Server"] --> CM
        FSR -->|"serializes client refs"| Stream["RSC Stream"]
    end

    subgraph "Runtime (Client)"  
        Stream --> FCL["Flight Client"]
        FCL --> CM2["Client Manifest"]
        CM2 -->|"resolves refs to modules"| Components["Loaded Components"]
    end

client reference{ $$typeof: REACT_CLIENT_REFERENCE, $$id: "app/Button.js#default" } のようなオブジェクトです。Flight Serverはこれをコンポーネントの型として検出した場合、レンダリングを試みずに参照としてシリアライズします。クライアント側では、Flight Clientがバンドラーのマニフェストを通じて $$id を解決し、webpackチャンクとエクスポート名を特定します。

server reference は逆方向に機能します。Server Actionがclient componentへのpropとして渡されると、クライアントがコールバックできる参照になります。通常はサーバーへのHTTP POSTを介して、サーバー側で元の関数に解決されます。

補足: RSCアーキテクチャでは、サーバーとクライアントの両方で同じ react パッケージを使いますが、exportコンディションが異なります。サーバーでは import React from 'react' が(react-server コンディション経由で) ReactServer.js に解決され、サーバー互換のAPIのみがエクスポートされます。これにより、Server Componentで誤って useState を使おうとしても、そのエクスポート自体が存在しないため防ぐことができます。

全体像

この6本の記事を通じて、Reactのアーキテクチャをmonoリポジトリの構造とビルドシステムから追ってきました。コンポーネントを表すFiberデータ構造、それを処理するワークループ、状態を与えるhooksシステム、DOMとの橋渡しをするhost config、そしてコンポーネントをブラウザに届く前にレンダリングするサーバーアーキテクチャへとたどり着きました。

graph TD
    subgraph "Your Code"
        JSX["JSX Components"]
    end

    subgraph "react package"
        API["Public API<br/>createElement, hooks stubs"]
        SI["SharedInternals.H<br/>(dispatcher bridge)"]
    end

    subgraph "react-reconciler"
        Fiber["Fiber Tree<br/>(double-buffered)"]
        WL["Work Loop<br/>(beginWork ↓ completeWork ↑)"]
        Hooks["Hook Implementations<br/>(linked list on fiber)"]
        Lanes["Lane Model<br/>(31-bit priority)"]
        Commit["Commit Phase<br/>(3 sub-phases)"]
    end

    subgraph "react-dom-bindings"
        HC["Host Config<br/>(createInstance, commitUpdate)"]
        Events["Event System<br/>(delegation, SyntheticEvent)"]
    end

    subgraph "react-server"
        Fizz["Fizz SSR"]
        Flight["Flight RSC"]
    end

    JSX --> API
    API --> SI
    SI --> Hooks
    Hooks --> Fiber
    WL --> Fiber
    Lanes --> WL
    WL --> Commit
    Commit --> HC
    HC --> Events
    JSX --> Fizz
    JSX --> Flight

すべてのピースはつながっています。forkシステムが環境ごとのバンドルを生成するビルドパイプラインを実現し、Fiberデータ構造がレンダリングをまたいで状態を保持し、laneモデルが処理を優先順位付けし、ワークループがfiberを効率的に処理し、hooksはディスパッチャーブリッジを介してfiberに状態を格納し、host configが抽象的な操作を実際のDOM操作に変換します。

Reactのアーキテクチャは、産業規模での関心の分離を体現したケーススタディです。reconcilerはDOMを知りません。react パッケージはreconcilerを知りません。SchedulerはReactを知りません。各ピースは狭く明確に定義されたインターフェースで通信し、forkシステムが実行時ではなくビルド時にそれらを結びつけることで、本番環境での抽象化コストをゼロに抑えています。