React 18以降の React Server Components (RSC) では、コンポーネント内でPromise(非同期処理)を直接扱うことが可能になり、サーバーサイドでのデータ取得とUIレンダリングが密接に統合されています。その結果、クライアント側での追加のデータフェッチや煩雑な状態管理なしに、初期表示を高速化したりストリーミングで部分的にUIを表示したりできます。本レポートでは、RSCにおけるPromiseの扱いについて、サーバーコンポーネントとクライアントコンポーネント間のデータ受け渡し方法、PromiseへのID割り当てと管理、そしてロードバランサ環境への影響まで、動作原理を詳しく解説します。
React Server ComponentsにおけるPromiseの扱い
React Server Componentsでは、非同期データ取得をコンポーネント内部で直接行い、その結果を待ってレンダリングを行うことができます。ReactはSuspense機構によって、Promiseが解決するまでレンダリングを一時停止(サスペンド)し、準備ができたところからUIを再開する仕組みを提供します。具体的には、コンポーネントのレンダリング中に非同期処理がまだ完了していない場合、ReactはそのPromiseをthrow
してレンダリングを中断し(このthrow
はエラーではなく一時停止のシグナルとして機能します) (やっと React Server Components が腑に落ちた #JavaScript - Qiita)。これにより、未解決のデータ部分にはローディングUI(<Suspense>
のfallbackで指定した待機画面)が表示され、他の準備できている部分のHTMLは先にクライアントへストリーミング送信されます。その後、Promiseが解決されるとReactは再度コンポーネントのレンダリングを試み、今度は取得できたデータを含めて完全なUIを生成します (やっと React Server Components が腑に落ちた #JavaScript - Qiita) (Why are React Server Components actually beneficial? (full history))。
React 18では、この非同期処理をシンプルに扱うために実験的なフックであるuse()
が導入されました。use()
フックにPromiseを渡すと、以下のように振る舞います (Promises across the void: Streaming data with RSC):
- Promiseがすでに解決済み: 即座にその解決値を返します(同期的に値が得られます)。
-
Promiseが未解決(pending): コンポーネントのレンダリングをサスペンドし、Reactは最近傍の
<Suspense>
のfallbackを表示します (Promises across the void: Streaming data with RSC)。内部的にはuse()
がそのPromiseをthrow
するため、前述のSuspense機構が働いてPromise完了を待ちます (Promises across the void: Streaming data with RSC)。 -
Promiseが拒否(reject): エラーとして
throw
し、最近傍のError Boundaryでキャッチされます (Promises across the void: Streaming data with RSC)。
このようにuse()
を使うことで、クラスコンポーネント時代にあったようなcomponentDidMount
でのデータ取得やuseEffect
での状態更新を明示的に書かなくても、宣言的に「このデータが来るまで待つ」という処理をコンポーネント内に記述できます。Reactは裏側でPromiseの状態を追跡し、解決するまでレンダーを一時停止、解決後に自動で再レンダリングしてくれるため、開発者は非同期処理を直感的に記述できるようになっています (やっと React Server Components が腑に落ちた #JavaScript - Qiita)。
また、RSCではサーバーコンポーネント自体をasync
関数として定義し、await
でデータ取得を待つことも可能です(Next.js 13のApp Routerではページコンポーネントをasync
にして直接fetch
をawaitするコードが書けます)。async/await
を用いる場合も基本原理は同じで、解決されていないPromiseに遭遇した時点でそのコンポーネントのレンダリングは一時停止され、他の部分のHTMLが先に送信されます。その後、Promiseが解決されたら再開して残りを送信します。ただしasync/await
だけでは並列処理が難しいケースもあります。その点、use()
フックを使えば一つのコンポーネント内で複数のPromiseを待ちつつ、各Promiseの完了に応じて順次UIを描画するような柔軟な制御が可能です (React Server Components, without a framework?) (React Server Components, without a framework?)。いずれにせよ、RSCにおけるPromiseの扱いはSuspenseとストリーミングを基盤としており、非同期データ取得をReactがレンダリングプロセスに組み込んで管理している点が重要です。
サーバーコンポーネントからクライアントコンポーネントへのPromiseの渡し方
サーバーコンポーネントで取得したデータのPromiseを、そのままクライアントコンポーネントに渡して処理を継続させることもできます。ポイントはサーバー側でそのPromiseをawait
しないことです (Promises across the void: Streaming data with RSC)。サーバーコンポーネント内でawait
してしまうと、結局データが解決するまでサーバー側のレンダリング全体が待たされ、結果としてクライアントにはPromiseの解決後の値しか渡らずストリーミングの利点を活かせません(ユーザーはその間UIが何も表示されず待たされてしまいます)。代わりに、Promiseオブジェクトをそのままクライアントコンポーネントのpropsとして渡すことで、データ取得の待機をクライアント側に委ね、他の部分のレンダリングを先行させることができます (Promises across the void: Streaming data with RSC)。
例えば次のようなコードを考えてみます(擬似コード):
// サーバーコンポーネント側
import ClientComp from './ClientComp.client';
export async function getData() {
// データをフェッチしてPromiseを返す関数(非同期処理)
const res = await fetch('https://example.com/api/data');
return res.json();
}
function ServerComp() {
const dataPromise = getData(); // ※ここでawaitしない
return <ClientComp data={dataPromise} />;
}
// クライアントコンポーネント側 (use clientディレクティブをファイル先頭に指定)
"use client";
import { use } from 'react';
export default function ClientComp({ data }) {
// サーバーから渡されたPromiseをuse()で利用
const result = use(data);
return <div>データ: {JSON.stringify(result)}</div>;
}
上記の例では、ServerComp
(サーバーコンポーネント)はgetData()
から得たPromiseをClientComp
にdata
プロパティとして渡しています。ServerComp
自身は非同期関数ではなく(サーバー上でPromiseの完了を待たない)、返り値のJSXに未解決のPromiseを含めてしまいます。一方、ClientComp
(クライアントコンポーネント)では、受け取ったdata
(これはPromiseオブジェクト)をReactのuse()
フックで使用しています。ClientComp
内でconst result = use(data)
とすることで、ReactはこのPromiseが解決されるまでClientComp
のレンダリングをサスペンドし、データ取得完了後に再開します。
クライアントコンポーネントは現状ではasync
関数として定義できないため(クライアント側でのasyncコンポーネントは未サポート)、上記のように通常の関数コンポーネント内でuse()
を使う形になります (Promises across the void: Streaming data with RSC)。つまりクライアントコンポーネント自体は同期的に定義しつつ、内部でuse()
を呼ぶことで非同期処理を待機するわけです。このときクライアント側でもSuspenseが働くため、例えばサーバーコンポーネント側で <Suspense fallback={<p>Loading...</p>}>
を利用してClientComp
をラップしておけば、データが来るまで「Loading...」といったプレースホルダーUIが表示され続けます(サーバーはそのフォールバックUIを先にストリーム送信できます)。上記コードでも、use(data)
でデータを待っている間は自動的にサスペンドされるため、ユーザーにはその間fallbackのローディング表示が見えることになります。データ取得が完了すればuse()
から実際のresult
が得られ、クライアント側でコンポーネントの内容が最終的に更新・表示されます。
要するに、サーバーコンポーネントではPromiseを解決せずにそのまま渡し、クライアントコンポーネントでuse()
によってそのPromiseを利用するという形で、サーバーからクライアントへ非同期処理をシームレスに引き継ぐことができます。このときReactは裏で適切にSuspenseとストリーミングを調整し、ユーザーにできるだけ早く初期UIを届けつつ、遅れて届いたデータでUIを更新するという流れを実現しています。
PromiseのID割り当てとその管理方法
では、サーバーからクライアントにPromiseを渡す裏側で、ReactはどのようにそのPromiseを追跡・管理しているのでしょうか。Reactはサーバー上でレンダリング中に見つかった未解決のPromiseに対して一意のIDを割り当て、クライアントには実体ではなくそのIDを渡します (Promises across the void: Streaming data with RSC)。さらに、Promiseの解決時にはそのIDに紐づいたデータをクライアントに送信し、クライアント側で対応する場所に値を埋め戻します。高レベルの流れは次のとおりです (Promises across the void: Streaming data with RSC):
-
サーバーでPromise検出: サーバーコンポーネントのレンダリング中に、未解決のPromiseがクライアントコンポーネントのpropsとして渡されようとしているのを検知します(前述の例では、
dataPromise
がそれに当たります) (Promises across the void: Streaming data with RSC)。Reactはこれを「まだ解決していないリソースがある」というサインとみなし、その場で完全な値を出力する代わりにSuspense状態に入ります。 -
Promiseに内部IDを割り当て: サーバーはそのPromise専用の内部ID(一意の識別子)を生成します (Promises across the void: Streaming data with RSC)。このIDは現在のリクエスト内でPromiseを識別するためのタグのようなものです。Reactの実装上は、Promise(Thenable)のシリアライズ時に
serializeThenable()
という関数で新しいタスクとIDが作成され、そのIDが予約されます (How do React Server Components(RSC) work internally in React?) (How do React Server Components(RSC) work internally in React?)。 -
プレースホルダーとしてIDを送信: サーバーはそのクライアントコンポーネントに本来渡すはずだったPromiseの代わりに、生成したIDをプレースホルダーとして埋め込んだデータを出力します (Promises across the void: Streaming data with RSC)。具体的には、Next.jsの場合サーバーは特殊な形式でシリアライズされたRSCペイロード内にこのPromise IDを含めて送ります。クライアントは受け取ったIDを認識し、「ここに後でデータが入る」ということを把握します(ReactはそれをPromise未解決としてSuspense扱いします)。
-
クライアントでレンダリング待機: クライアント側のReactは、props中に「Promise ID」が渡されてきたことを検知すると、そのコンポーネントのレンダリングをサスペンド(保留)します (Promises across the void: Streaming data with RSC)。この時点ではまだデータが来ていないため、該当コンポーネントは表示用のデータが揃っておらず、Fallback UI(ローディング表示)のままとなります。
-
サーバーでPromise解決・データ送信: サーバー側でそのPromise(データ取得処理)が完了すると、サーバーはストリーム中の適切なタイミングで解決したデータを含むチャンクをクライアントにプッシュします。具体的には、小さなスクリプトタグや特殊なデータチャンクにより「先ほどIDを付与したPromiseが解決し、結果はこれである」という情報を送信します (Promises across the void: Streaming data with RSC) (Promises across the void: Streaming data with RSC)。Next.jsでは例えば以下のようなスクリプトが送られます(ID=9のPromiseに対応する結果配列を送信する例):
<script>self.__next_f.push([1,"9:[{\"id\":1,\"name\":\"Alice\"}, ... ]"])</script>
上記はNext.jsの内部キュー
__next_f
に対して、ID「9」のPromise結果を追記する処理を行うスクリプトです。 -
クライアントでPromise解決・再レンダリング: クライアント側でそのスクリプトが実行されると、React(Next.js)は保持していたPromise IDと照合し、該当するコンポーネントのpropsに実データを埋め戻します (Promises across the void: Streaming data with RSC)。先の例では、Promise ID「9」に対するデータを
ClientComp
(例のTableコンポーネント)のdataPromise
プロパティに復元します (Promises across the void: Streaming data with RSC)。このとき内部的には、クライアント上でも一旦Promiseオブジェクトが再構築されてからresolveされるような形になっていますが、開発者から見ると最初に渡したPromiseがそのまま解決したように扱えます (Promises across the void: Streaming data with RSC)。データが供給されたことでSuspenseが解除され、保留中だったコンポーネントのレンダリングが再開されます。結果として、サーバーから遅れて送られてきたデータがクライアント側UIに反映され、ユーザーには完全な情報が表示されます。
以上のステップにより、Reactはサーバー・クライアント間でPromiseを追跡し、非同期データを逐次ストリーミングで渡しています。重要なのは、この処理がReactのランタイムによって自動的に管理される点です。開発者は単にPromiseを返しているだけですが、裏側ではIDによる対応付けやスクリプトによる値注入が行われています。React自身がPromiseにIDを振って管理することで、どのデータがどのコンポーネントに属するかを正確に把握し、一貫した再レンダリングを保証しているのです (Promises across the void: Streaming data with RSC)。この仕組みにより、サーバーとクライアントで別々にPromiseの状態を持つ必要はなく(サーバー側Promiseはクライアント側で同等のものに再構成されますが、それはフレームワークが面倒を見てくれる部分です)、開発者は単純に「遅延するデータ」を他のprops同様に扱うことができます。
ロードバランサの影響 (サーバー識別不要)
RSCのストリーミング動作は、ロードバランサ配下のサーバー環境でも特別なセッション管理を必要としないよう設計されています。通常、あるユーザーのリクエストを複数のサーバーで処理する場合、途中で担当サーバーが変わるとセッション情報や一時データの整合性が問題になります。そのためロードバランサでは「スティッキーセッション(セッション固着)」といって、同じユーザー(セッション)のリクエストは同じバックエンドサーバーに送り続ける設定をすることがあります。しかしReact Server Componentsの場合、単一のHTTPリクエストの中で全てのUIデータのストリーミングが完結するため、途中で別のサーバーに再リクエストを送る必要がありません (やっと React Server Components が腑に落ちた #JavaScript - Qiita)。
実際、RSCではサーバーからのレスポンスがストリームとして逐次クライアントに送られ、すべての非同期処理(Suspenseで保留された部分)が解決されて必要なデータを送り終えるまで、そのHTTP接続は開いたままになります (やっと React Server Components が腑に落ちた #JavaScript - Qiita)。一つのリクエストに対し最初に送られるのは準備できている部分のHTML(あるいはRSCのシリアライズデータ)で、その中には未解決の部分に対してマーカー(穴)が入っています (Why are React Server Components actually beneficial? (full history)) (Why are React Server Components actually beneficial? (full history))。そしてサーバー側でデータが準備でき次第、同じレスポンスのストリームに後続のデータ片を送り込み、クライアントはそれを受信して先の穴埋めを行います (Why are React Server Components actually beneficial? (full history)) (Why are React Server Components actually beneficial? (full history))。サーバー上ではこの一連の処理が終わった段階でようやくレスポンスを閉じる(コネクションを終了する)ため、クライアントから見ると一度のHTTPリクエストが段階的に完結する形になります (やっと React Server Components が腑に落ちた #JavaScript - Qiita)。
このアプローチにより、ロードバランサ配下でも各リクエストはそれぞれ単一のサーバー上で開始から終了まで処理される前提となります。途中で追加のデータ取得のために新たなリクエストを発行したり、別のサーバーに問い合わせ直す必要がないため、特定のサーバーにユーザーを固定するような仕組み(例えばセッションIDでサーバーを識別してリクエストを同じノードに送り続けるような仕組み)は不要です。言い換えれば、RSCのストリーミングは各リクエストごとに自己完結しているため、どのサーバーがそのリクエストを処理したかを後から意識する必要がありません。ロードバランサは各リクエストを空いている任意のサーバーに振り分ければよく、各サーバーは受け取ったリクエストに対して最後のデータチャンクまで責任を持って送り返すだけです。その後の新しいリクエストは、また別のサーバーが処理しても全く問題なく動作します。
以上のように、React Server Componentsのストリーミング機構により、ロードバランサ配下でもサーバーを特定する必要がなくなっています。ストリーミングレスポンスは常に最初にリクエストを受け取ったサーバーから送り出されるため、途中で通信相手が変わることはありません。このシンプルさはスケーラビリティの面でも利点であり、RSC採用時に特別なインフラ構成変更(例えばsticky sessionの有効化等)をしなくても、複数サーバー間でシームレスに動作させることができます。
参考資料: React公式ドキュメントや有志による技術ブログにRSCとPromiseに関する詳細な解説があります。 (やっと React Server Components が腑に落ちた #JavaScript - Qiita) (やっと React Server Components が腑に落ちた #JavaScript - Qiita)ではサーバー側でのSuspenseとストリーミングの挙動、 (Promises across the void: Streaming data with RSC)ではuse()
フックの基本動作、 (Promises across the void: Streaming data with RSC)ではサーバーからクライアントへのPromise受け渡しの流れが説明されています。また、Next.jsを用いた具体例としてEd Spencer氏のブログ記事 (Promises across the void: Streaming data with RSC)が、サーバー内でPromiseにIDを割り当ててクライアントに配信し、クライアント側でそのIDに基づいて値を再構成する仕組みを解説しています。これらの情報も合わせて参照すると、React Server ComponentsにおけるPromise処理の理解がさらに深まるでしょう。