POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

FeedlyRSSTwitterFacebook
Tony Alicea

本記事は、原著者の許諾のもとに翻訳・掲載しております。

React Server Componentsを理解する

React Server Components(RSC)の登場により、Reactエコシステムにおけるサーバーレンダリングの重要性が高まりました。RSCを使用することで、デベロッパーは一部のコンポーネントをサーバー側でレンダリングしつつ、抽象化によりクライアントとサーバーの隔たりを感じさせないユーザビリティを実現することができます。Client ComponentsとServer Componentsをコード内に混在させることで、すべてのコードが1カ所で実行されているように見せることができます。

しかし、抽象化には常にコストが伴います。そのコストとはどのようなものでしょうか。RSCはいつ「使える」のでしょうか。バンドルサイズが小さくなると、帯域幅も狭まるのでしょうか。RSCを「使うべき」ときはいつでしょうか。RSCを適切に使う上でデベロッパーが従うべきルールは何でしょうか。そのルールが存在する理由は何でしょうか。

これらの問いに答えるため、RSCの仕組みを詳しく見ていきましょう。React本体と、ReactのメタフレームワークというRSCの2つの側面について考察します。特に、RSCに関する正確なメンタルモデルを構築できるよう、ReactとNext.jsの内部構造について説明します。

none

この記事は、Reactを使い慣れたデベロッパーを対象としています。読者には、コンポーネントやフックに関する予備知識が前提条件として求められます。

また、JavaScriptのPromise、async、awaitについても熟知していることを想定しています。これらに関する知識がない方は、Promise、async、awaitの仕組みに関する筆者のYouTube動画をご覧ください。

Reactのあらゆる側面についてゼロから詳しく知りたい方は、筆者の「Understanding React(Reactを理解する)」コースをぜひチェックしてみてください。このコースでは、Reactのソースコードを掘り下げることで、JSX、Fiber、コンポーネント、フック、フォームなどの仕組みを理解していただくことができます。

まずは、RSCの仕組みを理解するために必要な基本を見ていきましょう

DOMとクライアントレンダリング

Reactでは「レンダリング」という表現を固有の意味で使っています。一般的にはブラウザがページを「レンダリング」するという場合、DOMを画面に描画する処理をいいます。ブラウザは、DOM(要素のツリー構造)とCSSOM(計算済みスタイルのツリー構造)をもとに、各要素の配置を計算し、適切なピクセルを画面に描画します。

一方、Reactでは「DOMの見た目を計算すること」を「レンダリング」といいます。関数コンポーネントが返す値はReactに対し、DOMの見た目に関する情報を伝えます。

したがって、React(およびReactに追随する形で登場した他のフレームワーク)の世界では、「クライアントレンダリング」という場合、ブラウザ上で関数コンポーネントを実行することをいいます。

Reactでいう「レンダリング」は、必ずしもブラウザによる実際のレンダリングを伴いません。なぜなら、DOMの見た目がすでにReactがこうあるべきと考える見た目になっている場合があるからです。

実際、Reactのコアアーキテクチャ(および他のすべてのJSフレームワーク)における重要な点は、内部コードが更新するDOMの量を制限することにあります。

ツリーの差分検出処理

Reactのソースコードの中には、appendChildなどのブラウザDOM APIを呼び出し、クライアント上のDOMを更新するコールがあります。Reactは、ツリーの差分検出処理(reconciliation)や差分計算(diff)によって、ブラウザDOM APIを実行するタイミングを決めます。

React Compilerに関する記事に書いたように、ReactはDOMの現在の見た目と、見た目がどうあるべきかをJavaScriptオブジェクトツリーで管理しており、各ノードはFiberと呼ばれます。

Reactは、JavaScriptオブジェクトツリーの2つの枝でDOMの見た目がどうあるべきか(「WORK-IN-PROGRESS」)を計算し、DOMの現在の見た目(「CURRENT」)と比較します。

次に、2つのツリーの差分を検出し、currentツリーをworkInProgressツリーに変換するために必要なステップを計算します。そのステップは「diff」と「patch」(パッチ処理)です。

単純なJavaScriptオブジェクトとの比較計算が終わると、実際のDOMにおいて取るべきステップが明らかになります。DOMの更新はコストがかかり、ブラウザの再レンダリング(要素の配置とピクセルの描画)を伴うため、最低限取るべきステップの数を特定することで、DOMに対して必要な更新を最小限にとどめることができます。

クライアント上でDOMを更新すれば、UIを更新する際にstateを保持できるメリットがあります。例えば、ユーザーがフォームに情報を入力し、Reactが何らかのイベントをもとにUIを更新しても、入力したテキストはフォーム上に保持されます(ページが再読み込みされた場合は保持されません)。

したがって、Reactではクライアント上でDOMを更新することを重視しており、最初に偽のDOMに対して処理を行うことで可能な限り効率的に更新しようと努めています。このようなJavaScriptのDOM構造の偽コピーを一般的に「仮想DOM」と呼びます。

none

「仮想DOM」は適切な表現か?

ReactにおけるDOMと似た構造のJavaScriptオブジェクトの集まりを以前は「仮想DOM」と呼んでいました。しかし、実際はiOSやAndroidのネイティブアプリ(React Native)などへのレンダリングが可能なため、Reactは今ではこの呼称を好んでいません。

実際、Reactは複数のツリーを扱います。関数コンポーネントが返すReact要素(JSオブジェクト)のツリーや、React要素を変換したもので、stateなどを格納するために使用される Fiber(同じくJSオブジェクト)のツリーなどです。

普段はより具体的な名前でこれらのツリーを呼びますが、この記事では、RSCの仕組みを理解するうえで便利なので、昔から日常会話でよく使われている「仮想DOM」という呼称を使いたいと思います。

Reactが「レンダリング」と呼ぶのは、仮想DOMの計算処理です。具体的には、関数コンポーネントを実行し、リアルDOMの見た目がどうあるべきかを決めることをいいます。この処理はすべて関数を実行するJavaScriptエンジンの中で、リアルDOMに反映される差分が全く検出されなくなるまで行われます。

Reactが「レンダリング」という表現を使う理由を理解すると、RSCを正確に説明するうえで非常に役立ちます。

RSCを理解するためには、Web開発におけるクライアントレンダリングおよびサーバーレンダリングと、Reactが重点を置く仮想DOMの生成の違いを明確に理解する必要があります。

Reactでいう「レンダリング」では、実際には必ずしも何かが目に見える形で起こるわけではありません。

「レンダリング」という言葉のさまざまな意味については、確認しながら説明を進めたいと思います。一般的な(React以外での)定義を「古典的」と呼び、Web開発の用語として定義してみましょう。

none

レンダー【render】 【動詞】 /réndər/

  1. (古典的クライアントサイド)DOMとCSSOMをもとにレイアウトを計算し、画面にピクセルを描画すること。
  2. (Reactクライアントサイド)仮想DOMを構築して更新するために関数コンポーネントを実行すること。

DOMとサーバーレンダリング

RSCについて説明を進めるには、時間を遡る必要があります。長年、インターネットの中心概念の一つとして、サーバーからHTMLを配信するという考え方があります。

サーバー上にHTMLファイルを(NodeJSやPHPなどのサーバー技術を使って)作成することを、古典的な「サーバーサイドレンダリング」や「サーバーレンダリング」と呼びます。これは、先ほど説明した古典的なクライアントレンダリングの意味とも違います。従来、サーバーレンダリングとはサーバー上で「HTMLの文字列を生成する」ことを意味しました。

これにはいくつかの利点があります。ブラウザはHTMLを素早くDOMに変換することができます。そのため、HTMLは「ブラウザ上で素早くレンダリング」されます。JavaScriptでDOMを更新すると、もっと時間がかかります。また、サーバーの方がデータベースやファイルストレージに近いため、これらの処理をより効率的に行うことができます。

デメリットとしては、クライアントは再度HTMLを要求することができますが、stateが失われてしまいます(ページが再読み込みされる)。

Web開発においては、そのバランスをどう取るかが長年課題となっていました。サーバー側でレンダリングされたHTMLは素早く表示されますが、クライアント側のJavaScriptを通じてDOMを更新すると、ページのstateを維持したまま変更を加えることができます。

Reactでは両方行えますが、それは何も新しいことではありません。Reactはクライアント側のJavaScriptを使ってDOMを更新しますが、デベロッパーはかなり前からReactのコンポーネントをサーバー側でレンダリング(SSR)することができていました。

サーバー(NodeJSなどを通じて独自のJavaScriptエンジンを実行)は、コンポーネントを実行し、クライアントに送信するHTML文字列を生成します。ただし、大きな注意点があります。同じコンポーネントのJavaScriptコードもすべてクライアントに送信して実行する必要があったのです。

それはなぜでしょうか。関数コンポーネントが返す値をもとに仮想DOMを構築できるようにするためです。仮想DOMはリアルDOMを「ハイドレート」するために使用します。これは例えば、ボタンがクリックされたときに、どの関数コンポーネントの中のどのクリックイベントを実行するべきかがわかるということです。覚えておかなくてはならないのは、Reactが機能するためには、クライアント上に両方のツリー(DOMと仮想DOM)が存在する必要があるということです。したがって、ReactにおけるSSRでは、関数を2回実行する必要があります(1回はサーバー上でHTMLを生成するため、もう1回はクライアント上で仮想DOMを作成するため)。

参考までに、SSR/ハイドレーションプロセスを以下に可視化しています。

ここでReact Server Componentsの登場です。RSCは、サーバー上で実行されるReactコンポーネントとクライアント上で実行されるReactコンポーネントを、サーバーコンポーネントのJavaScriptコードの送信と再実行を行うことなく混在させることができます。さらに、ブラウザ上でDOMの更新を始める前に、最初にサーバー上でHTMLをレンダリングすることも可能です。

なぜ可能なのでしょうか。

まずは用語の定義を更新しましょう。

none

レンダー【render】 【動詞】 /réndər/レンダリング

  1. (古典的クライアントサイド)DOMとCSSOMをもとにレイアウトを計算し、画面にピクセルを描画すること。
  2. (Reactクライアントサイド)仮想DOMを構築して更新するために関数コンポーネントを実行すること。
  3. (古典的サーバーサイド)DOMを構築するためにクライアントに送信するHTMLを生成すること。
  4. (ReactサーバーサイドSSR)DOMを構築するためにクライアントに送信するHTMLを生成するために関数コンポーネントを実行すること。
none

サーバーサイド生成はどうか?

ここで取り上げていないものの一つがSSG(サーバーサイド生成)です。これは、アプリをビルドする(デプロイ可能にする)際に、HTMLをあらかじめ生成することを意味します。これは、Client ComponentsとServer Componentsの両方に対して行えます。

SSGの定義は、作成中の辞書収録項目におけるSSRの定義と同じです。この記事では、SSGとSSRの違いを明らかにしてもあまり役に立たないので詳しくは述べませんが、SSGもサポートされています。

先ほど、Reactが機能するためには、DOMと仮想DOMの両方のツリー全体がブラウザのメモリ上に存在しなくてはならないとお話ししました。では、React Server Componentsがサーバー上でのみ実行されればよく、クライアントがJavaScriptコードをダウンロードして実行する必要がないのはなぜでしょうか。

つまり、Reactは「サーバー」上で実行された関数によってレンダリングされた部分について、どのようにして「ブラウザ」上に仮想DOMを構築するのでしょうか。

Flight

サーバー上で関数コンポーネントを実行し、その結果をもとにクライアント上に仮想DOMを構築できるようにするために、Reactはサーバー上で実行された関数が返したReact要素ツリーを「シリアライズ」する機能を追加しました。

多くの場合、シリアライズは「コンピューターのメモリ上のオブジェクトを文字列に変換する」こと、デシリアライズは「文字列をコンピューターのメモリ上のオブジェクトに復元する」ことを意味します。

この場合、関数コンポーネントの結果はシリアライズしてクライアントに送信する必要があります。

筆者のReactコースに登録した生徒の数を把握するための簡単なアプリを作成するとします。まずはNext.jsで基本的なRSCを作成します。これはサーバー上で実行されます。

export default function Home() {
  return (
    <main>
      <h1>understandingreact.com</h1>
    </main>
  );
}
none

同型コンポーネント

サーバーとクライアントの両方で実行可能なコンポーネントを「同型(isomorphic)」と呼びます。上の関数はサーバーに特化したこと(データベースに直接接続する、サーバー上のファイルを読むなど)を何も行わないため、クライアント上で実行することも可能であり、Reactは通常どおり直接その結果をもとに仮想DOMを構築することができます。

関数が同型である場合、共有することができます。Server ComponentsとClient Componentsのどちらもそれをインポートして使用することができます。

この関数を実行するためにクライアントに送信しなくてもいいように、その結果をシリアライズする必要があります。Reactのコードベースの中では、このシリアライズ形式を「Flight」と呼び、送信したデータの総和を「RSC Payload」と呼びます。

筆者の簡単な関数の結果をシリアライズしたものが以下です。

"[\"$\",\"main\",null,{\"children\":[\"$\",\"h1\",null,{\"children\":\"understandingreact.com\"},\"$c\"]},\"$c\"]"

分析しやすいようにフォーマットしてみましょう(Alvar Lagerlöf氏が作成したRSCパーサーを使用)。

{
  "type": "main",
  "key": null,
  "props": {
    "children": {
    "type": "h1",
    "key": null,
    "props": {
      "children": "understandingreact.com"
    }
}

仮想DOMの構造が見えるでしょうか。main要素とh1要素、プレーンテキストノードもあります。props、特にReact特有の標準のchildren propsとして渡されているものも確認できます。

ここでは単純化した例を用いて説明していますが、フォーマットの要素はこれだけではありません。また、メタフレームワークを使用するとさらに多くの要素が追加されます。例えば、「Flight」を表す「f:」など、ツリーに追加するものを表す識別子などです。しかし、ここで必要な理解を得るためには単純化した例で十分です。

シリアライズのフォーマットはReactが提供していますが、Payloadを作成し、クライアントに送信する作業はメタフレームワーク(この場合はNext.js)が行う必要があります。

例えば、Next.jsはコードベースにgenerateDynamicRSCPayloadという関数があります。

メタフレームワークは、Payloadが確実に生成され、クライアントに送信されるようにします。Payloadのおかげで、Reactはクライアント上で正確な仮想DOMを構築し、差分検出処理を行うことができます。

メタフレームワークとサーバーレンダリング

先ほど、RSCからHTMLのレンダリングを行うことは「可能」だと話しました。そのように言ったのは、それが任意だからです。つまり、RSCからHTMLをレンダリングするかどうかはメタフレームワーク次第です。とは言え、そうすることは理にかなっています。

後で話すように、「体感パフォーマンス」は重要な指標です。すでにサーバー上でコードを実行していて、HTMLをストリーミングにより返せる場合、そうするべきです。なぜなら、ブラウザがそのHTMLを素早くレンダリングし、ユーザーの体感パフォーマンスが向上するからです。

メタフレームワークが遅いと感じられれば、誰も使いません。したがって、RSCを実装するReactのメタフレームワークは、古典的サーバーレンダリングとReactサーバーレンダリングの両方を行う必要があります。

古典的なサーバーレンダリング(HTMLを生成)ではページのレンダリング(ブラウザによる描画)が速く、Reactスタイルのサーバーレンダリング(RSC Payload)では後で行われるステートフルな更新のための仮想DOMが得られます。

したがって、実際にはRSCは「二重データ問題」を引き起こします。サーバーから同じ情報をHTMLとPayloadという2つの異なる形式で同時に送ることになります。これらはDOM(HTML)と仮想DOM(Payload)を即座に構築するために必要な情報です。

以下の図をご覧ください。

例では、Next.jsがHTMLを返し、ブラウザはそれをもとにDOMを構築します。

<main>
  <h1>understandingreact.com</h1>
</main>

さらにPayloadも返し、Reactはそれをもとに仮想DOMを構築します。

{
  "type": "main",
  "key": null,
  "props": {
    "children": {
    "type": "h1",
    "key": null,
    "props": {
      "children": "understandingreact.com"
    }
}

HTMLを送ることで、ブラウザがページを素早くレンダリングすることができます。ユーザーの画面には、ページが直ちに表示されます。また、Payloadを送ることで、Reactがページをインタラクティブにするために必要な作業を完了することができます。

このデータの重複に伴うコストは、サーバーがレスポンスを送る前に使う圧縮アルゴリズム(gzipなど)によって相殺できるという意見もあります。しかし、HTMLとJSONのようなPayloadは形式が異なるため、繰り返しの部分はあいまいになってしまい、帯域幅には二重データによる明らかな影響が生じます。

抽象化にはコストが伴います。ここでのコストは同じ情報を2回送る必要があることです。

これらのことを踏まえると、「レンダリング」の定義は全部で5つになります。

none

レンダー【render】 【動詞】 /réndər/レンダリング

  1. (古典的クライアントサイド)DOMとCSSOMをもとにレイアウトを計算し、画面にピクセルを描画すること。
  2. (Reactクライアントサイド)仮想DOMを構築して更新するために関数コンポーネントを実行すること。
  3. (古典的サーバーサイド)DOMを構築するためにクライアントに送信するHTMLを生成すること。
  4. (ReactサーバーサイドSSR)DOMを構築するためにクライアントに送信するHTMLを生成するために関数コンポーネントを実行すること。
  5. (ReactサーバーサイドRSC)仮想DOMを構築し、更新するためにクライアントに送るFlight(Payload)データを生成するために関数コンポーネントを実行すること。

Reactの定義に共通する類似点にお気づきでしょうか。Reactでは、レンダリングは常に「関数コンポーネントの実行」を意味し、Client ComponentsとServer Componentsはいずれも仮想DOMの構築と更新に必要なものを提供します。

Streams、Suspense、RSC

アプリケーションを構築する際にはパフォーマンスが常に懸念点となります。しかし、パフォーマンスには2種類あります。実際のパフォーマンスと体感パフォーマンスです。

HTTPとブラウザは、両方のパフォーマンスを改善する方法として、長年ストリーミングをサポートしてきました。NodeJSのStream APIや、ブラウザのStreams API(特に、ブラウザのReadableStreamオブジェクト)などです。

React(およびRSCをサポートしたいメタフレームワーク)は、これらのコアテクノロジーを利用してHTMLとPayload両方のデータをストリーミングします。ストリーミングとは、「チャンク」と呼ばれる少量のデータに分けて送信することを意味します。クライアントは、その少量のデータを受け取ったものから順に処理することができます。 したがって、ストリーミングの場合は「何を送ったか」ではなく、「一定時間内に何を送ったか」が問題となります。

ブラウザは、ネットワークを介したHTMLのストリーミングに対応できるよう設計されています。ストリーミングで送られてくるHTMLを受け取りながらページのレンダリング(配置と描画)を行います。

同様に、Reactは後に解決してRSC PayloadデータになるPromiseを受け入れます。例えば、Next.jsはクライアント上にReadableStreamを設定し、サーバーからのストリームを読み込み、受け取ったものからReactに渡します。サーバーレンダリングに対するReactの全体的なアプローチとしては、必要な場所にコンテンツをストリーミングで送る方式が中心となっています。

実際、Flight形式自体に未完了の処理を示すマーカーが含まれています。Promiseや遅延読み込みなどです。

例えば、サーバーコンポーネントをasync関数として設定し、タイマーを待つとします。

// components/Delayed.js
async function delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

export default async function DelayedMessage() {
    await delay(5000); // 2 second delay
    
    return (
        <p>This message was loaded after a 5 second delay!</p>
    );
}

// page.js
import DelayedMessage from "./components/DelayedMessage";

export default function Home() {
  return (
    <main>
      <h1>understandingreact.com</h1>
      <DelayedMessage />
    </main>
  );
}

async関数はPromiseを返します。その結果、Payloadは以下のようになります。

{
  "type": "main",
  "key": null,
  "props": {
   "children": [
    {
     "type": "h1",
     "key": null,
     "props": {
      "children": "understandingreact.com"
     }
    },
    {
     "$$type": "reference",
     "id": "d",
     "identifier": "L",
     "type": "Lazy node"
    }
   ]
  } 
}

DelayedMessageコンポーネントがあるべき場所が、特別な識別子”L”でマーキングされている点に注目してください。これは、後でコンテンツが挿入される場所を示しているのです。

このコードを実行すると、遅延メッセージだけでなく、ページ全体が読み込まれるのに5秒かかります。

これは、Reactがクライアント向けに設計された特別なSuspense機能を使用してPromiseや遅延読み込みに対処するためです。コンポーネントを更新してSuspenseを使うようにすると、以下のようになります。

import DelayedMessage from "./components/DelayedMessage";
import { Suspense } from "react";

export default function Home() {
  return (
    <main>
      <h1>understandingreact.com</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <DelayedMessage />
      </Suspense>
    </main>
  );
}

この状態でページを実行すると、最初にフォールバックが表示され、遅れているメッセージが5秒後に表示されます。しかし、このコンポーネントがまだサーバー上で実行されていることに注目してください。どうすればサーバー上でSuspenseを使えるのでしょうか。使えません。関数から返されたPayloadをクライアント上で処理すると、境界を含む仮想DOMが構築されます。

Payloadは以下のようになります。

{
  "type": "main",
  "key": null,
  "props": {
   "children": [
    {
     "type": "h1",
     "key": null,
     "props": {
      "children": "understandingreact.com"
     }
    },
    {
     "type": {
      "$$type": "reference",
      "id": "d",
      "identifier": "",
      "type": "Reference"
     },
     "key": null,
     "props": {
      "fallback": {
       "type": "p",
       "key": null,
       "props": {
        "children": "Loading..."
       }
      },
      "children": {
       "$$type": "reference",
       "id": "e",
       "identifier": "L",
       "type": "Lazy node"
      }
     }
    }
  }
}

フォールバック(props)と、Promiseが解決した後で読み込まれるもの(「遅延ノード(Lazy node)」、この場合はDelayedMessage)がいずれも含まれていることに注目してください。

Payloadは、チャンク化してストリーミングするとともに、Promiseが後で解決される仮想DOMの場所を参照します。そうすることで、Reactと、RSCをサポートするメタフレームワークは、最短時間でUIを表示し、実際のパフォーマンスとユーザーの体感パフォーマンスのいずれも改善しようとしています。

しかし、ストリーミングされたFlightデータはどこに送られるのでしょうか。Reactコードベース内のどこかであるはずです。

ReactにPayloadを渡す

RSCをサポートするために、ReactはFlight形式(文字列)を受け取り、parseModelStringなどの関数でReact要素に変換する機能をコードベースに追加しました。

適切なデータを送信し、これらのReact APIを実行するかどうかはRSCをサポートするメタフレームワーク次第です。

例えば、Next.jsの場合は、アプリにラッピングコンポーネントを追加し、そこにPayloadデータをストリーミングにより送信します。

これは以下のようになります。

<ServerRoot>
  <AppRouter
      actionQueue={actionQueue}
      globalErrorComponentAndStyles={initialRSCPayload.G}
      assetPrefix={initialRSCPayload.p}
  />
</ServerRoot>

Next.jsは、コンポーネントツリーのAppRouterの上に、ServerRootというコンポーネントを追加します。そこからAppRouterにRSC Payloadデータをストリーミングします。

そのデータは、最終的にReactのPromiseベースのAPI(Flight形式を受け取るためのAPI)にストリーミングされます。

このように、ReactはPayloadをもとに仮想DOMを構築するためのAPIを提供し、Next.js(またはRSCをサポートするメタフレームワーク)には、サーバー上でコンポーネントが実行された後、そのデータをReactに渡す独自の仕組みが備わっています。

順不同ストリーミング

ストリーミングについて話すべき点は他にもあります。異なるコンポーネントの実行が異なるタイミングで完了する場合があります。チャンク化されたPayloadがストリーミングされてきたとき、Reactは仮想DOM(およびDOM)のどこにデータを置くべきかをどのようにして判断しているのでしょうか。

DelayedMessageコンポーネントを使用したDOMを再び見てみると、最初は以下のようになっています。

<main>
  <h1>understandingreact.com</h1>
  <!--$?-->
  <template id="B:0"></template>
  <p>Loading...</p>
  <!--/$-->
</main>

Reactは、特殊なIDが設定されたtemplateのようなプレースホルダーと、Suspenseが待っているPromiseが解決した時点でのコンテンツの挿入先を記載したHTMLコメントを残します。

フォールバックはDOMの中にありますが、Promiseが解決すると新しいJavaScriptがページにストリーミングされてきます。

$RC = function(b, c, e) {
  c = document.getElementById(c);
  c.parentNode.removeChild(c);
  var a = document.getElementById(b);
  if (a) {
      b = a.previousSibling;
      if (e)
          b.data = "$!",
          a.setAttribute("data-dgst", e);
      else {
          e = b.parentNode;
          a = b.nextSibling;
          var f = 0;
          do {
              if (a && 8 === a.nodeType) {
                  var d = a.data;
                  if ("/$" === d)
                      if (0 === f)
                          break;
                      else
                          f--;
                  else
                      "$" !== d && "$?" !== d && "$!" !== d || f++
              }
              d = a.nextSibling;
              e.removeChild(a);
              a = d
          } while (a);
          for (; c.firstChild; )
              e.insertBefore(c.firstChild, a);
          b.data = "$"
      }
      b._reactRetry && b._reactRetry()
  }
}
;
$RC("B:0", "S:0")

このコードは、Promiseの解決後に新たに作成されたDOMのコンテンツを、プレースホルダーが残されていた適切な場所に挿入し、プレースホルダーとフォールバックを削除します。

このDOM操作コードが実行された後、DOMは以下のようになります。

<main>
  <h1>understandingreact.com</h1>
  <!--$-->
  <p>This message was loaded after a 5 second delay!</p>
  <!--/$-->
</main>

これを順不同ストリーミングといいます。これは単に、ストリーミングされてきたコンテンツを、先に完了する他のコンポーネントより前の場所であっても、仮想DOM/DOMツリーの所定の場所に挿入することを意味します。

そうすることで、特定のコンポーネントの実行に時間がかかったとしても、その完了を待たずにUIを更新し、他のコンポーネントの結果を反映することができます。

しかし、ここまではServer Components についてしか見ていません。デベロッパーが何年も前から書いてきたコンポーネントについてはどうでしょうか。ブラウザ上で実行される関数であるClient Componentsはどうでしょうか。

それについて語るうえで欠かせないのが、RSCを支える縁の下の力持ちとも言えるバンドラです。

バンドラとインターリービング

昔から、Reactの基本的な考え方の一つとしてコンポーネントコンポジションが挙げられていました。DOMの構造を決める作業はさまざまな関数に分けて行い、各コンポーネントを親子関係によって組み合わせることができます。

RSCがこの基本的な考え方から大きく離れないようにするためには、Server ComponentsとClient Componentsをインターリーブ(混在させる)できなくてはいけません。Client Componentsは、Server Componentsの子になれる必要があります。これには、props(関数の引数)を渡せることも含みます。

実際これが何を意味するかというと、コンポーネント階層の中には、サーバー上で実行される関数と、クライアント上で実行される関数があるということです。しかし最終的には、どの関数も自らが生成するDOMコンテンツの構造と中身を計算します。

これを実現する役割を担うのがメタフレームワークとバンドラです。しかし、抽象化はコストを伴うことを忘れてはいけません。抽象化を利用するために特別なルールを学ばなくてはいけないことがコストであることも少なくありません。この場合、サーバーとクライアントの隔たりをある程度抽象化し、気にならないようにするには、抽象化の制限を破らないようルールに従う必要があるということです。

RSCのケースでは、考慮すべきインターリービングのシナリオが3つあります。ルールは、コンポーネントが実行される場所に応じて何を「インポート」できるかに関わるもので、指示やインポートを分析してまとめるバンドラとRSCがどのように連携するかに基づいています。

Client Components を Server Components にインポート

このインポートは可能です。それが可能であるのは理にかなっています。バンドラはインポートステートメントを見て、どのコードをバンドルに含め、どのコードがクライアントによってダウンロードされるかを判断します。

RSCも仮想DOMの構築に参加します。Client Componentコードがバンドルに含まれブラウザに渡されるため、ツリーに含まれるClient Componentsを参照することができます。

Reactコースへの登録ページにステートフルなCounterを追加してみましょう。

// components/Counter.js
'use client';
import { useState } from 'react';

export default function Counter() {
    const [count, setCount] = useState(0);

    return (
        <section>
            <p>{count}</p>
            <button onClick={() => setCount(count + 1)}>
                Enroll
            </button>
        </section>
    );
}

// page.js
import Counter from "./components/Counter";
import DelayedMessage from "./components/DelayedMessage";
import { Suspense } from "react";

export default function Home() {
  return (
    <main>
      <h1>understandingreact.com</h1>
      <Counter />
      <Suspense fallback={<p>Loading...</p>}>
        <DelayedMessage />
      </Suspense>
    </main>
  );
}

ファイル上部にuse clientという指示があります。これはReactの機能ではありません。コンポーネントツリーの一部をクライアント上で実行する場合は、マーキングすることがデベロッパーの間では暗黙の了解になっています。

そのコンポーネントと、それにインポートされるコンポーネントが、Client Componentsとしてバンドルされます。

バンドラはuse clientの指示を確認し、そのコンポーネント(およびインポートされるコンポーネント)のコードをブラウザがダウンロードするバンドルに含めます。

Home RSCおよびDelayedMessage RSCはサーバー上で実行されるため、これらのコードはバンドルに含まれません。サーバーから送られるPayloadは以下のようになります。

{
  "type": "main",
  "key": null,
  "props": {
   "children": [
    {
     "type": "h1",
     "key": null,
     "props": {
      "children": "understandingreact.com"
     }
    },
    {
     "type": {
      "$$type": "reference",
      "id": "d",
      "identifier": "L",
      "type": "Lazy node"
     },
     "key": null,
     "props": {}
    },
    {
     "type": {
      "$$type": "reference",
      "id": "e",
      "identifier": "",
      "type": "Reference"
     },
     "key": null,
     "props": {
      "fallback": {
       "type": "p",
       "key": null,
       "props": {
        "children": "Loading..."
       }
      },
      "children": {
       "$$type": "reference",
       "id": "f",
       "identifier": "L",
       "type": "Lazy node"
      }
     }
    }
   ]
  }
}

Client Componentが挿入される場所に「遅延読み込み(Lazy node)」のリファレンスが新たに追加されていることに注目してください。仮想DOMの当該部分は、そのClient Componentが実行されるときに分かります。つまり、Client Componentがサーバー上でレンダリング(フレームワークが対応している場合)されるか、ブラウザ上で実行されるときです。

もう一つ述べておきたいのが、Server ComponentからClient Componentにpropsを渡す場合、そのpropsはReactによってシリアライズする必要があるということです。

すでに見てきたように、propsはネットワークを介して送信されるPayloadに含まれます。つまり、データは全て文字列として渡し、クライアントのメモリ上でオブジェクトに復元する必要があるということです。

Client ComponentsへのServer Componentsのインポート

このインポートは認められません。サーバー上で実行されるコンポーネントを、ブラウザ上で実行されるコンポーネントにインポートすることはできません。

なぜかと言うと、バンドラがクライアントに送信するのはPayloadのみであり、RSC関数は送信するべきではないからです。したがって、インポートするコードはありません。バンドラはクライアントがダウンロードできるようにコードを含めることはないため、使用できるRSCコードはありません。

サーバーとクライアントの両方で実行可能な共有コンポーネントをインポートすることは可能です。しかし、Client Componentに共有コンポーネントをインポートする場合、そのコードはクライアントがダウンロードできるようバンドルされます。Server Componentに共有コンポーネントをインポートする場合はバンドルされません。

「間違ってServer Componentをインポートした場合、バンドラはそれが間違いだと分かるのか」と疑問に思うかもしれません。

これは妥当な疑問です。これはセキュリティ上の問題でもあります。ダウンロードされて他人の目に触れることを想定していないコードがServer Componentに含まれる場合、間違ってClient Componentにインポートし、バンドルされてしまうことがあるかもしれません。サーバー固有の機能(データベースへの接続など)が含まれる場合、ブラウザ上で実行することはできませんが、そのまま本番環境にリリースされてしまうと、データベースのアドレスなどの機密情報が漏洩する可能性があります。

Next.jsは対策として、コンポーネントに「サーバーのみ(server-only)」とマーキングできるようにしています。しかし、これは何かを忘れないように指に紐を結びつけておくようなものです。紐を結ぶのを忘れてしまうこともありえます。

他のメタフレームワークは、サーバーのコードがバンドルされ、クライアントに送信されないようにするより確実な方法を検討しています。

しかし、一度サーバーとクライアントの境界を抽象化してしまうと、その境界が存在すること自体忘れてしまう一定のリスクを受け入れることになります。

Server Componentsを子としてClient Componentsに渡す

これは可能です。これは興味深い、特殊なケースです。Server Componentsをchildren propsとしてClient Componentに渡すことはできます。これはインポートとは異なります。

Counter関数にchildrenを渡した場合、以下のようになります。

// components/Counter.js
'use client';
import { useState } from 'react';

export default function Counter({ children }) {
    const [count, setCount] = useState(0);

    return (
        <section>
            <p>{count}</p>
            <button onClick={() => setCount(count + 1)}>
                Enroll
            </button>
            { children }
        </section>
    );
}

// page.js
import Counter from "./components/Counter";
import DelayedMessage from "./components/DelayedMessage";
import { Suspense } from "react";

export default function Home() {
  return (
    <main>
      <h1>understandingreact.com</h1>
      <Counter>
        <p>Server Text</p>
      </Counter>
      <Suspense fallback={<p>Loading...</p>}>
        <DelayedMessage />
      </Suspense>
    </main>
  );
}

Counter関数はクライアント上で実行され、このコンポーネントに渡された子(<p>Server Text</p>)はサーバー上で処理されますが、問題なく機能します。

なぜ機能するのかと言うと、実行するServer Componentコードではなく、仮想DOMツリーの一部(コードを実行した結果)を渡しているからです。

Payloadは以下のようになります。

{
   "children": [
    {
     "type": "h1",
     "key": null,
     "props": {
      "children": "understandingreact.com"
     }
    },
    {
     "type": {
      "$$type": "reference",
      "id": "d",
      "identifier": "L",
      "type": "Lazy node"
     },
     "key": null,
     "props": {
      "children": {
       "type": "p",
       "key": null,
       "props": {
        "children": "Server Text"
       }
      }
     }
    },
    {
     "type": {
      "$$type": "reference",
      "id": "e",
      "identifier": "",
      "type": "Reference"
     },
     "key": null,
     "props": {
      "fallback": {
       "type": "p",
       "key": null,
       "props": {
        "children": "Loading..."
       }
      },
      "children": {
       "$$type": "reference",
       "id": "f",
       "identifier": "L",
       "type": "Lazy node"
      }
     }
    }
   ]
  }
 }

Payloadの"Server Text"の部分に注目してください。すでにpropsとしてClient Componentに渡されています。Client Componentで直接PayloadにJSXを書いたのと変わりません。

バンドラ:縁の下の力持ち

これらは全てある重要な点を示しています。React Server Componentsは、多くの点においてバンドラ機能だということです

バンドラはコードを分析し、Client Componentsがバンドルに含まれていることを確認し、Client ComponentsへのリファレンスがPayloadに適切に含まれるようにします。

バンドラはReactに欠かせない存在です。Reactのコードベースを見ると、以下のようなフォルダがあります。

/react-server-dom-parcel
/react-server-dom-turbopack
/react-server-dom-webpack
//...and more

これらのフォルダの中にはFlight関連のコードが含まれており、バンドルされたコードが適切に実行されるようにします。

バンドラがRSCにおいて脇役的な存在であるということは、他の方法も可能だということです。メタフレームワークは、Next.jsが使用するuse clientのアプローチを受け入れる必要はありません。例えば、TanStack Startは単に「JSXを返す」(Flight形式)関数としてRSCを実装しています。

Reactは、FlightデータのストリーミングというAPIを提供しています。メタフレームワークはバージョンアップを行い、そのAPIを工夫して使うことができます。

フックとRSC

サーバー上で実行することにはメリットもありますが、制約もあります。 Reactは、各要素の構造を仮想DOMに格納するだけではなく、stateを格納します。コンポーネントに以下のように書くとします。

const [counter, setCounter] = useState(0);

そうすると、仮想DOMにおけるコンポーネントの場所に紐づけられた連結リスト上のノードにデータが置かれます。実際には、そのstateはクライアントのブラウザのメモリ上にあるJavaScriptオブジェクトの中にあります。

したがって、React Server Componentsは、その性質上これらのフックを利用できません。フックを利用できる環境で実行されていないのです。

つまり、RSCは「非インタラクティブ」だということです。Reactにおけるインタラクティビティとは、一般的にはクライアント側のReactによる再レンダリングを、stateの更新によってトリガーすることを意味します。

そのため、アプリのインタラクティブ機能が増えるにつれ、Server ComponentsをClient ComponentとServer Componentのコンポジションに組み替えるようになります。

useReduceruseStateなどが必要な場合は、Client Componentが必要になります。

コンポーネントがどこで実行されるのかを念頭に置いておくと、フックを適切に使用する(または使用しない)ことができます。

ハイドレートするか否か

よく誤解される点について少しお話ししたいと思います。RSCはハイドレートするのか、という点についてです。

答えは否です。ハイドレーションとは、イベントをハンドラにフックできるよう仮想DOMを構築するために、実際の関数をクライアント上で再実行することです。

Reactでは、ボタンをクリックすると、そのイベントはDOMツリーの最上部にあるReactのルートまで送られ、どのコンポーネントがクリックを処理するかをReactが判断します(答えは、ボタンの作成に関わったコンポーネントです)。

したがって、Reactがイベントに適切に対応できるよう、仮想DOMと、クリックを処理するコードがなくてはいけません。 RSCは非インタラクティブです。少なくともReactの通常のアプローチでは、stateの設定も、クリックの処理も行いません。実行するためにコードをクライアントに送ることもないので、当然ながらハイドレートしません。

しかし、仮想DOMの構築には関わっています。ツリーの差分検出処理にも関わっています。ハイドレートしないからと言って、ハイドレーション時にツリー上に存在しないわけではありません。存在します。

再フェッチと差分検出

実際のアプリでは、ページの初回ローディングだけでなく、サーバーコンポーネントの再フェッチについても考える必要があるでしょう。

つまり、サーバーにコンポーネント(場合によっては新しいpropsを含む)を再実行するよう指示し、仮想DOMを更新するために新しいPayloadデータを送ってもらうということです。

例えば、RSCが生成したデータリストにページ番号を付けていく場合、ルートが/page/1/page/2であるかによって、異なるデータセットを取得することが望まれます。

これはRSCの利点であり、おそらくメタフレームワークのルーターに組み込まれています。UIはサーバー上で計算されますが、メタフレームワークはページ全体を再度読み込む必要がありません。

RSCはその性質上、仮想DOMの定義をクライアントにストリーミングすることができ、Reactは通常どおりクライアント側で差分検出処理を行うことができます。つまり、ページを再度読み込む必要がなく、ページ上の他のstateも失われません。

この点において、RSCはサーバーレンダリングとクライアントレンダリングの両方の長所を提供できます。サーバー上で実行しながら、クライアント上で実行された場合と同じように更新することができます 。 RSCの仕組みについて説明しましたので、より直感的にご理解いただけるのではないかと思います。Reactはすでに仮想DOMの差分計算によってDOMを更新していますので、仮想DOMのデータをサーバーから取得できれば、Reactは通常の動作を行うことが可能です。

バンドルサイズに関する誤解

10年以上前から、使用するツールの仕組みを深く理解することの重要性を説いてきました。筆者のコースでは、一貫してこのテーマについて扱ってきました。

多くの生徒がこのアプローチの重要性を理解してくれていますが、「理論が多すぎる。実践から学べばいい」と不満を漏らす生徒もたまにいます。

しかし、使用するツールやライブラリ、フレームワークの仕組みを理解することの主な価値の一つが、正確な情報に基づき、アーキテクチャに関する効果的な判断ができることです。

例えば、Next.jsの世界ではRSCのメリットについて以前から誤解がありました。__next_f()関数について、Next.jsコードのリポジトリでは素晴らしい議論が繰り広げられています。

RSCを使い始めたデベロッパーは、ページ下部のscriptタグでこの関数に重複データが渡されていることに気づきました。なぜそこにこの関数があるのか疑問に感じ、無効にできないか尋ねるデベロッパーもいました。

この重複データは何でしょうか。そう、Payloadです。ストリーミングされてきた関数コールは、仮想DOMを作成するため、最終的にそのPayloadデータをReactに渡します。仕組みを理解していなければ、これはかなりショッキングなことでしょう。

問題は帯域幅の使用量が増えることであり、多くのデベロッパーがその点について不満を述べていました。ネットワークを介して送るデータ量が増えているのです。

なぜデベロッパー達は驚いたのでしょうか。当初、VercelはNext.jsのドキュメンテーションサイトでRSCについて次のように説明していました。

バンドルサイズ:Server Componentsでは、以前ならクライアントJavaScriptのバンドルサイズに影響するような大きな依存関係をサーバー上に残すことができます。クライアントはServer ComponentsのJavaScriptをダウンロードして解析し、実行する必要が一切ないため、これはインターネットの通信速度が遅い、あるいはデバイスの処理能力が低いユーザーにとって有益です。

「クライアントはServer ComponentsのJavaScriptをダウンロードして解析し、実行する必要が一切ない」という文言が誤解を招いたようです。これは実際には正しくありません。VercelはServer Componentsの実際のJavaScriptコードについて言っていたのですが、「一切」という言葉を使ってしまいました。

筆者はこれに関連し、VercelとSNS上で興味深い会話をしました。この会話が、後日文言が変更されるきっかけになったのかもしれません(文言を変更したVercelに感謝)。

パフォーマンス:Server Componentsは、パフォーマンスをベースラインから最適化する手段を提供します。例えば、Client Componentだけで構成されるアプリから始めた場合、UIの非インタラクティブ要素をServer Componentsに移動することで、クライアント側が必要なJavaScriptの量を減らすことができます。ブラウザがダウンロードして解析し、実行するクライアント側JavaScriptの量が減るため、これはインターネットの通信速度が遅い、あるいはデバイスの処理能力が低いユーザーにとって有益です。

新しい説明には、Server Componentsが「クライアント側が必要なJavaScriptの量を減らすことができる」と書いてあります。それは事実です。しかし、Payloadはある意味JavaScript、少なくとも、JavaScriptの関数に渡されるデータなので、JavaScriptの量を増やすこともあります。

デベロッパーたちが誤解していた重要な点は、バンドルサイズと帯域幅の使用量は同じではないということです

Server Componentのコードはバンドルに含まれていないので、バンドルサイズは小さくなります。しかし、Payloadのデータは倍増しますので、データが大きいと(この記事のような長編のブログ記事など)節約できるバイト数以上のデータを送信することになります。

RSCはいつ使うべきか

では、RSCはいつ使うべきなのでしょうか。大抵の場合そうであるように、「状況次第」というのが正しい答えです。RSCの仕組みに関する正確なメンタルモデルが構築できていれば、適切なアーキテクチャを選択できるはずです。

筆者の場合、このような長編のブログ記事にはRSCを使用しません。帯域幅の使用量が多くなり、理にかなわないからです。コンテンツの多いサイトやアプリには、Astroなどを使用します。

一方、データベースへのアクセスが多く、ロジックが複雑な場合、Server Componentを使ってサーバーに処理させるかもしれません。大容量のJavaScriptライブラリを使用して比較的少量のコンテンツを作成する必要がある場合も同様です。バンドルとPayloadのトレードオフが価値に見合うようなら、RSCは理にかなっていると思います。

高度にインタラクティブなアプリを開発していて、頻繁にバージョンアップを行って機能を追加しているような場合も、Client Componentsを中心に運用し、クライアントとサーバー両方を使用するようなリファクタリングは極力控えると思います。

明確な使い方を推奨するには不確定要素があまりにも多すぎます。この記事を通じて試みたように、読者が正確な情報をもとに判断できるよう、理解を深める手助けをするのが筆者にできる精一杯のことです。

今後の展望

React Server Componentsの未来はどのようなものになるでしょうか。それは必ずしも明らかではありません。ReactはAPIを作り、メタフレームワークがそれを利用しています。

筆者の考えでは、TanStack Startが採用した、完全なServer Componentsではなく、仮想DOMを返す関数を用いるアプローチが普及すると思います。しかし、用途によってはNext.jsのアプローチも有効だと思います。

セキュリティとパフォーマンスも徐々に改善して欲しいと思います。例えば、仮想DOMのある枝が全てServer Componentsの場合、差分検出処理やハイドレーションを最適化することでその枝をスキップするようにできると思います。

もっと詳しく学びたい方へ

この記事がReact Server Componentsの理解を深める一助になれば幸いです。Reactの全てについて、このようにゼロから詳しく学びたい方は、筆者の完全版「Understanding React(Reactを理解する)」コースにぜひご登録ください。

27個のモジュールと、16.5時間の動画コンテンツを通じてReactのソースコードを詳しく学び、デベロッパーにとって最も有益なツールである正確なメンタルモデルを構築することができます。

それではまた!

監修者
監修者_古川陽介
古川陽介
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
複合機メーカー、ゲーム会社を経て、2016年に株式会社リクルートテクノロジーズ(現リクルート)入社。 現在はAPソリューショングループのマネジャーとしてアプリ基盤の改善や運用、各種開発支援ツールの開発、またテックリードとしてエンジニアチームの支援や育成までを担う。 2019年より株式会社ニジボックスを兼務し、室長としてエンジニア育成基盤の設計、技術指南も遂行。 Node.js 日本ユーザーグループの代表を務め、Node学園祭などを主宰。