一言で理解するReact Server Components
皆さんこんにちは。最近Next.js 13.4がリリースされ、App Routerがstable扱いになりました。App RouterはReact Server Component (RSC) をふんだんに用いて構築されています。
React本体でServer Componentがずっとalpha版なのにNext.jsでbetaとかstableとか言ってるのは何で? という問題も、React Canaryのアナウンスにより無事に解消されました。
React Canaryってなに?
先日React公式ブログでアナウンスされた、新しいリリースチャネルです。
筆者の理解による概要をお伝えすると、Canary版のReactでは「まだ破壊的変更が入ることが予期されるが、Reactが公式にサポートできる程度には安定した新機能」を使うことができます。Canary版のReactは、主にフレームワーク(Next.jsなど)を介して使われることを想定しています。
Canaryの機能はそこそこ高い頻度で破壊的変更が行われることが予想されるので、まだセマンティックバージョニングに従ったリリースに載せるには時期尚早なものです。フレームワーク層が頑張ってその破壊的変更を吸収することにより、一般ユーザーは頻繁なメジャーアップデートを避けられるというわけです。フレームワークを介さずにCanaryを使う場合、自分で破壊的変更に付いていく意志が必要です。
大きな新機能を開発する場合、やはり実際に使ってもらってフィードバックを受ける必要がありますが、破壊的変更が予期されるものはなかなか使ってもらえないという問題があります。これまでReactはMeta社内で実験的な新機能を使ってもらうことでこの問題に対応していたそうですが、React Canaryの登場により、一般ユーザーにも新機能を使ってもらいより高速にフィードバックを得ることができるようになります。
React Canaryの登場によりReactの開発体制が多少変わることが予想されます。具体的には、フレームワーク開発者とより緊密に連携する必要がある代わりに、Meta社内への依存が減ります。何とは言いませんが、何か感じるものが無くはないですね。フレームワーク開発者の筆頭はもちろんVercelです。
とはいえ、React Server Componentsは何だか難しいと思っている読者の方も多いでしょう。確かに、これまでとメンタルモデルが異なるところもあり、難しく感じられます。そこで、この記事ではReact Server Componentsを理解する助けとするべく、筆者なりの解説を行います。
一言でReact Server Componentsを理解する
一言で言うと、React Server Componentsは多段階計算です。
多段階計算は、(いろいろな定義があると思いますがここでは)「プログラムの評価を多段階に分けて処理する機構」[1]「動的にコードを生成してそれを走らせる機構を備えた,計算が複数のステージからなる意味論を備えた体系(+それを安全に行うための型システム)」[2]のように理解するとよいでしょう。
多段階計算は、要するに「プログラムを生成するプログラム」を扱うものです。メタプログラミングやマクロなども、この意味で多段階計算に近いものです。マクロなどではなく多段階計算という言葉を使う場合、多段階の処理以外にも以下の性質が重要視されるように見えます。
- 上記2つ目の引用で強調されているように、多段階にまたがるプログラムでも静的検査により安全性を担保できる。
- 2段階にとどまらず多段階への一般化ができることを踏まえて、異なる段階のプログラムが似たような構文・意味論を持つ。
RSCの最も基本的なメンタルモデルは、「Reactアプリケーションの中に、サーバーで実行される部分とクライアント側で実行される部分がある」ということです。また、順序的にはサーバー→クライアントの順序で実行されます。これはまさに「プログラムの評価を多段階に分けて処理」していると言って問題ないでしょう。これは、多段階計算の中でも2段階のものです。
また、RSCでは「サーバー側」も「クライアント側」もReactコンポーネントとして記述されるので、「異なる段階のプログラムが似たような構文・意味論を持つ」と言えそうです。これらのコードはTypeScriptやESLintでチェックされるので、静的検査の部分も(完璧ではないかもしれませんが)良さそうです。以上が、RSCが多段階計算に近いものである理由です。
ちなみに、2段階の計算の場合は「stage 0のプログラム」と「stage 1のプログラム」があります。stage 0のプログラムを実行するとstage 1のプログラムが出力されて、stage 1のプログラムを実行すると最終結果が得られます。RSCの場合、「サーバー側」がstage 0、「クライアント側」がstage 1に相当します。
React Server ComponentsはPHPか?
RSCの話題では、よく「PHPの再来」「歴史は繰り返す」と言われることがあります。これらは、言った側はネガティブな意図を込めて揶揄しているつもりかもしれませんが、実は的を射た指摘です。なぜなら、PHPもまた、「サーバー側で実行→クライアント側で実行」というアーキテクチャになっているという意味では、多段階計算に近いものだからです。
PHPプログラムを実行すると、HTML(+JavaScript)が出力されます。これがクライアントに送信され、HTMLおよびJavaScriptがクライアント側で実行(解釈)されることによってWebページが完成するのです。PHPプログラムは、HTML+JavaScriptを生成するという意味で、「プログラムを生成するプログラム」であると言えます。stage 0がPHPの構文で、stage 1がHTML+JavaScriptで書かれます。PHPを書いたことがある方は、次のようにJavaScriptのプログラムの一部をPHPで出力したことがあるかもしれません。
<script>
var foo = <?php echo json_encode($foo) ?>;
</script>
とても原始的ではあるものの(上記1・2の条件は満たされていないし)、これは紛れもなく多段階計算の一例です。PHPでは、<?php 〜 ?>
で囲まれた部分がstage 0になっていると言えます。stage 0を実行する段階(PHPプログラムとして実行する段階)ではstage 1部分(<?php 〜 ?>
以外の部分)は何も実行せずにただ出力されます。つまり、「PHPはstage 0のプログラムであり、実行するとstage 1のプログラムが出力される。PHPは内部にstage 1のプログラムを埋め込むことができる構文を持つ」と言えます。こう書くと、なかなか多段階計算っぽさが出ています。
次のような例であれば、より「stage 0プログラムがstage 1のプログラムを出力している」という感じがしますね。
<!-- stage 0 プログラム -->
<script>
<?php
for ($i = 0; $i < 3; $i++) {
echo "console.log('{$i}');";
}
?>
</script>
↓↓↓ 実行結果 ↓↓↓
<!-- stage 1 プログラム -->
<script>
console.log('0');
console.log('1');
console.log('2');
</script>
他に、各種のテンプレートエンジンなども同様に、ある種の多段階計算の端くれと言えるでしょう。
ただし、RSCはPHPから歴史が一周してきたものですから、当時に比べて進化しています。RSCがPHPと異なる点は、2つの段階が両方ともReactで書かれていることです。つまり、PHPが「HTML+JavaScript」を出力するプログラムであったならば、RSC(のサーバー側)は「クライアント側用のReactアプリケーション」を出力するプログラムと言えます。
RSCによる多段階計算の例
「RSCのサーバー側がクライアント側用のReactアプリケーションを出力する」ということを具体例を通して見てみましょう。
// サーバー用コンポーネント
const App: React.FC = () => {
return (
<main>
<Section heading="第1章 はじめに">
<P>
本記事では、React Server Components(RSC)について解説します。
</P>
</Section>
<Section heading="第2章 なぜRSCが必要なのか">
<P>
RSCは、Reactを土台としたフレームワークの隆盛に伴って、Reactアプリケーション全体が大きくなりすぎてしまい、サーバーとクライアントで同じアプリケーションが実行されるというモデルが限界に達したという背景があります。
</P>
</Section>
...
</main>
);
}
// サーバー用コンポーネント
const Section: React.FC<React.PropsWithChildren<{
heading: string;
}> = ({ heading, children }) => {
return (
<section>
<h2 className="text-2xl font-bold text-gray-900">{heading}</h2>
<ShowMore>{children}</ShowMore>
</section>
);
}
// サーバー用コンポーネント
const P: React.FC<React.PropsWithChildren> = ({ children }) => {
return <p className="text-lg text-gray-800">{children}</p>;
};
// クライアント用コンポーネント
const ShowMore: React.FC = ({ children }) => {
const [showMore, setShowMore] = useState(false);
return (
<div>
<div style={{ blockSize: showMore ? 'auto' : '100px' }}>
{children}
</div>
<button onClick={() => setShowMore(true)} hidden={showMore}>もっと見る</button>
</div>
);
};
上の例は簡単なWebページをReactで作成した例です。ボタンがあり、ユーザー操作に反応する必要があるため、それを担当するShowMore
コンポーネントだけクライアント用コンポーネントにしています。それ以外のApp
, Section
, P
はサーバー用コンポーネントです。
上のアプリケーションはstage 0のアプリケーションです(一部にstage 1のコードが埋め込まれています)。そこで、これをstage 0として実行して、stage 1のプログラム(クライアント用のReactアプリケーション)を出力してみましょう。
以下のソースコードが文字通り出力されるわけではないものの、概念的には以下のようなものがクライアント用コードとなり、これがブラウザに送られることになります。
const ClientApp = () => {
return (
<main>
<section>
<h2 className="text-2xl font-bold text-gray-900">第1章 はじめに</h2>
<ShowMore>
<p className="text-lg text-gray-80">
本記事では、React Server Components(RSC)について解説します。
</p>
</ShowMore>
</section>
<section>
<h2 className="text-2xl font-bold text-gray-900">第2章 なぜRSCが必要なのか</h2>
<ShowMore>
<p className="text-lg text-gray-80">
RSCは、Reactを土台としたフレームワークの隆盛に伴って、Reactアプリケーション全体が大きくなりすぎてしまい、サーバーとクライアントで同じアプリケーションが実行されるというモデルが限界に達したという背景があります。
</p>
</ShowMore>
</section>
...
</main>
);
};
const ShowMore: React.FC = ({ children }) => {
const [showMore, setShowMore] = useState(false);
return (
<div>
<div style={{ blockSize: showMore ? 'auto' : '100px' }}>
{children}
</div>
<button onClick={() => setShowMore(true)} hidden={showMore}>もっと見る</button>
</div>
);
};
このように、Section
コンポーネントとP
コンポーネントがstage 0として実行されて、ただのHTMLになりました。一方、ShowMore
はクライアント用コンポーネントなので、残されています。こうすることで、Section
とP
はstage 1のアプリケーションには存在しないことになります。そのため、Section
・P
コンポーネントの定義をブラウザに送信する必要がありません。これによりバンドルサイズを少し削減できました。
ただし、これはある種のインライン展開のようなものであるため、中身が大きいコンポーネントを繰り返し使用している場合は、中身を全部ベタ書きすることで逆にサイズが増えてしまう可能性もあります。将来的にはPartial Hydrationのような技術が発達することでこの問題は克服される気がします。
Partial Hydrationに関する補足
いわゆるSSRやSSGでは従来から、全部展開した後のHTMLがブラウザに送られていました。RSCでもその1回しか展開後のHTMLを送らないのであれば、RSCを全部展開してもサイズが逆に増えることはありません。
しかし、現在のNext.js(13.4)を挙動を見る限り、RSCが解決されてただのHTMLになったところに対しても一応クライアント側でhydrationを行っているようです。つまり、展開後のHTMLは、SSRされたHTMLと、hydration用のコードの2回送られています。
この話を表で表すと次のようになります。
従来 | RSC(クライアント用コンポーネント) | RSC(サーバー用コンポーネント) | |
---|---|---|---|
SSRされるもの | 展開後のHTML | 展開後のHTML | 展開後のHTML |
hydration用のコード(partial hydrationなし) | コンポーネント定義 | コンポーネント定義 | 展開後のHTML |
hydration用のコード(partial hydrationあり) | コンポーネント定義 | コンポーネント定義 | 無 |
従来(RSC不使用)のSSRでは「展開後のHTML」と「コンポーネント定義一式」がクライアントに送られていましたが、RSCの場合、現状ではサーバー用コンポーネントに関しては「展開後のHTML×2」が送られることになります。Partial hydrationがうまく導入できれば、サーバー用コンポーネントに関しては「展開後のHTML×1」にできるので、これが実現すれば、SSRする前提であれば「サーバー用コンポーネントにすれば無条件で転送量が減る」と言えることになります。楽しみですね。
ちなみにRSCには、サーバー側用のコンポーネント (stage 0) からクライアント用のコンポーネント (stage 1) を使用できるが、クライアント用のコンポーネント (stage 1) からサーバー用のコンポーネント (stage 0) は使用できないというルールがあります。このことも多段階計算の枠組みで考えれば自然ですね。
「まずstage 0部分を全部実行しちゃってただのstage 1にする」という過程を踏むことになりますから、stage 0の実行段階(サーバー側で実行する段階)ではstage 1のコードは何も実行されず、その中身もいちいち調べられないわけです。stage 1のコンポーネントからstage 0のコンポーネントを使ってしまうと、「いざstage 1のコードを実行してみたらstage 0への依存が発見された」ということが起こり得てしまうので困ります。Next.jsでは、このようなことをESLintやnext dev
時のランタイムチェックにより未然に防いでいます。(next dev
時はともかく、ESLintは静的解析ですから、冒頭で説明した多段階計算の特徴が現れていますね)。
ただし、上の例(ShowMore
)のように、children
を使ってstage 1のコンポーネントの「子要素」としてstage 0のコンポーネントを使うことはできます。最近Dan先生がTwitterなどでchildren
の活用を積極的に推奨しているのもこれに関係があり、children
をうまく活用することでstage 1部分を最小限にしてほしいという狙いがあるのでしょう。
以上で、RSCがどのような動きをするのか、そしてサーバー側(stage 0)のコードに存在する種々の制限がなぜ存在するのかが理解できたのではないかと思います。詰まるところ、stage 0のコードは全部ただのHTMLに解決されてからクライアントに送信されるので、HTMLとしてシリアライズできない挙動(ユーザーの操作に反応する挙動全般を含む)は許可されないのです(Next.js 13.4で発表されたServer Actionsはその制限を破る機能となりそうですが)。
ちなみに、場合によっては「stage 0から再レンダリングする」ことも必要になります。例えば、Next.jsで別のページに遷移した場合です。これは純Reactにはない概念です(stage 1のコードはstage 0のコードの存在を感知しないため)。そのため、この部分はフレームワークが担当することになります。フレームワークを使っていてこれがReactの概念なのかNext.jsの概念なのか分からないと思った場合も、原理に立ち返って考えることで正しく判断できるでしょう。
React Server Componentsの理解と受容の仕方
RSCにおいては「コンポーネントがサーバー用とクライアント用に分類される」という新しい概念が登場し、しかもサーバー用のコンポーネントはuseState
が使えないなど制限があるため、ここを難しいと感じる人もいるようです。
しかし、筆者はどちらかというと、「新しいステージが追加された」という理解をしています。従来のReactアプリケーションは、stage 1だけが存在しました。クライアント用のReactアプリケーションひとつだけが存在したのです。SSRという技術もありましたが、これはクライアント向けのコードを無理やりサーバーサイドでも実行するものです。
一方、RSCでは従来のReactアプリケーション(stage 1)の前段に、新ステージとしてstage 0が追加されたのです。stage 0では、stage 1に比べて、ステートが無いなどの制限があります。よって、最も自然なRSCの受容の仕方は、「とりあえず全部stage 1(クライアント用)にしておけば従来通り。そこにstage 0(Server Component)を足していく」というものになるでしょう。
しかし、現在RSCを利用する簡単な方法であるNext.js(のappディレクトリ)では、デフォルトがstage 0で、stage 1のファイルには"use client"
という宣言を書かないといけないことになっています。つまり、従来とはデフォルトのステージが変わっているのです。ここにメンタルモデルの飛躍があり、一部の人々にとっては混乱の原因になっているのではないかと思います。
このようにデフォルトが変わっている理由の推測としては、RSCをいったん理解すれば、なるべくstage 0に寄せたほうが有利だと気付くからだと思われます。前述のような例外ケースもありますが、一般にはstage 0に寄せたほうが転送量的に有利です。また、コンポーネントの処理が減るので実行時のパフォーマンス的にも有利になります。stage 0(サーバー用)のコンポーネントは制限が多くありますが、その制限を解除するにはコンポーネントをstage 1(クライアント用)に移す必要があります。これは、ステートの使用などを解禁する代わりに、パフォーマンスの悪化にオプトインする行為だと見なせます。今どきのWebフロントエンドアプリケーションを作る人はバンドルサイズの増加に自覚的になるべきですから、筆者的にはこのようなモデルは望ましいと思います。
どうせRSCを理解してもらう必要があるなら、ついでにメンタルモデルの転換も済ませてもらったほうが理想的だということで、デフォルトがstage 0になったのではないかと推測します。
stage 0とstage 1の分け方
なぜ人々はフロントエンドでJavaScriptを使うのでしょうか? それは、UXのためです。ユーザーの操作に対して最速でフィードバックを返すためにはサーバーと通信するのは遅すぎるため、クライアントサイドのJavaScriptなどでフィードバックの処理を行う必要があります。このようなアプリケーションをうまく書けるようにするのがReactの役目です。
一方で、その目的のためにアプリケーション全部を(従来の)Reactで書くのはオーバーヘッドが大きすぎるということに人々が気づき始めたようです。Reactをただのテンプレートエンジンのように使っているコンポーネントも多く見られます。テンプレートエンジンというのは、基本的にはサーバーサイドで使われてきたものです。つまり、本来UXのためにはクライアントサイドのJavaScriptが必要だが、UXに関係ない部分はサーバーサイドで処理したほうが良いにもかかわらず、従来のReactでは全部クライアントサイドで処理してしまっていたのです。
RSCでは、「テンプレートエンジンとして使われているReactコンポーネント」をstage 0としてサーバー側に移すことができます。一方、ユーザーの操作に反応する部分は従来通りstage 1とする必要があります。
つまり、コンポーネントをstage 0にするかstage 1にするか迷った場合は、そのコンポーネントの役割を考えれば大抵は解決するでしょう。
Reactは、テンプレートエンジンとしてもなかなか優秀です。TypeScriptによる型チェックの恩恵を受けることができるし、そもそもstage 1ととてもシームレスに結合しており、「Reactアプリケーションを出力するためのテンプレートエンジン」として理想的です。
さらに、テンプレートエンジンとして考えれば、RSCがなぜ今のような形態になっているのか理解できます。皆さんはサーバーサイド用のテンプレートエンジンを使うとき、生で使うよりも何かのフレームワークに乗って使うことが多いのではないでしょうか。それはRSC(のサーバー側)であっても例外ではなく、Next.jsなどのフレームワークに乗って使うのを基本的なユースケースとしてデザインされています。
Next.jsでの例
ここまでは一応一般論としてRSCについて説明してきましたが、最後にNext.jsでの挙動についてもう少し踏み込んで説明します。というのも、RSCを多段階計算として見ると、すでに説明したとおり「サーバー側でstage 0を実行→クライアント側でstage 1を実行」という流れになります。では、「サーバー側でstage 0を実行」というのは具体的にどのように行われるのでしょうか。
実は、stage 0の実行にはいくつかのパターンがあります。これは、従来の用語で言うSSR・SG (Static Generation)・ISRなどの分類に似ています。対応するページがリクエストの情報に依存しないもの(いわゆるSG可能なもの)であれば、stage 0の実行はビルド時に行なってしまえばよいですね。stage 0の実行がリクエスト時の情報に依存するものであれば、実際にページにアクセスするたびにサーバー側でstage 0の実行が行われることになります。表で説明すると次のようになります(簡単のためrevalidate周りは省略)。
ページの種類 | stage 0の実行 | stage 1の実行 |
---|---|---|
リクエストの情報に依存しない | ビルド時 | クライアント側 |
リクエストの情報に依存する | リクエスト時 | クライアント側 |
ややこしいので注意してほしい点は、RSCの導入後は、「SSR」とは「サーバーサイドでもstage 1の実行を行い、生成されたHTMLをレスポンスに埋め込んで返すこと」を指すことになります。RSCより前の時代はSSRは「アプリケーション全体(stage 1のみ)をサーバーサイドでも実行すること」でしたから、RSCを「サーバー側にstage 0が追加されるもの」と考えれば理解できます。誰が何を実行するのかは次のように整理できます。
サーバー側 | クライアント側 | |
---|---|---|
従来型(SSRなし) | - | stage 1 |
従来型(SSRあり) | stage 1 | stage 1 |
RSC(SSRなし) | stage 0 | stage 1 |
RSC(SSRあり) | stage 0 + stage 1 | stage 1 |
Next.jsは、ページごとにリクエストの情報に依存するかどうかを判断する必要がありますが、その仕組みは従来(Pages Router)に比べると面白いものになっています。従来方式では、ページモジュールから getServerSideProps
がエクスポートされていればリクエスト時の情報が必要、なければSG可能……といった風に扱われていましたが、App Routerでは違います。
詳しくは公式ドキュメントに書いてありますが、App Routerでは、ビルド時に「stage 0のコンポーネントを実際に実行してみて、リクエスト時の情報を取得しようとしたかどうか」を見て判断するようです。例えば、リクエストヘッダーから情報を取得するcookies()
やheaders()
を呼び出したのであれば、問答無用でリクエスト時の情報が必要と判断されます。また、fetch
を使用した場合はキャッシュ関連のオプションを見て判断されます。cache: 'no-cache'
もrevalidate
も指定されていなければ永遠にキャッシュ可能と見なされ、ビルド時に取得されたデータがずっと使用されます。これらのオプションが指定されていた場合はビルド時に取得したデータをずっと使うわけにはいきませんから、ランタイムに(リクエスト時に)stage 0の実行が必要であると判断されます。
実際、例えばheaders()のソースコードを見ると、SG時にこれらの関数が呼ばれた場合はSG不可と判断して実行を中断するようになっているのが分かります。
まとめ
この記事では、「React Server Componentsはある種の多段階計算である」というアイデアをベースにしてRSCについて説明しました。Next.jsのようなフレームワークがやっていることは魔法のようですが、この記事で説明した基本的なアイデアを抑えていれば、Next.jsがどうしてそのような挙動をするのか理解できるはずです。
Discussion
「hydration用のコード」ってNext.jsが返すHTMLの末尾に付加されてる
<script>
要素のFlightプロトコルで表現されたデータのことで合ってますか?だとしたら、これは仮想DOMツリー (Fiber) のためのものじゃないでしょうか?
現状のReactではNext.jsのようにコンポーネントにstatic/dynamicといった色づけはしていないと思われるので、仮想DOMツリー上にはCCだけでなく全てのSCの情報もFiberデータ構造として保持する必要があるのだと思ってます
でないと再レンダリングでFlightプロトコルで表現されたレスポンスを受信しても実DOMを差分更新できないように思えるからです
そしてどのSCのどの部分が更新される可能性があるかを今のReactは知りようがないのでRoot Layoutの
<html>
要素から全てを仮想DOMツリー上に復元 (ハイドレーション) しているのかなと将来React Forget後継のコンパイラが完成すればstaticなSCについては再レンダリングによる更新が発生しないと確定できて、仮想DOMツリー上に当該コンポーネントのFiberデータ構造を持つ必要がないという最適化チックなことも可能になるかもしれませんね (それをこの記事ではPartial Hydrationと呼んでいるのかもしれませんが)
その場合でもdynamicなSCについてはやっぱりHTMLと共にFlightプロトコルで情報を送る必要が残る気がします
ありがとうございます。
そうです。
そうですね。ベーシックなアイデアとしては、例にある
ShowMore
以下だけが(クライアントから見た)Reactアプリケーションとして管理されていればよいと思いましたが、よく考えるとクライアントコンポーネントの下にサーバーコンポーネントが入ることを考えるとFiberのデータ構造のレベルで改良が必要そうですね。 🥲しかし、stage 0(サーバー側)で計算されたものを複数回クライアントに送信するというのは原理的にみて無駄なので、必然的に削減される方向に進むだろうと期待しており、この記事のような表現になっています。