👽

React Server Components 総まとめ

2021/02/11に公開

先日、React Server Componentsについてまとめる機会がありました。
この記事では、React Server Componentsの概要と、デモを触る中で感じたことについてご紹介します。

React Server Componentsとは

React Server Componentsは、Reactコンポーネントをサーバーサイドでレンダーする新しい技術です。
一部のコンポーネントをサーバーサイドでレンダーしてしまうことで、アプリケーションのパフォーマンスを上げることを目的とします。

Server ComponentとClient Componentの区分け図

図は、デモの画面のうち、サーバーでレンダーされる部分を青で、クライアントでレンダーされる部分を赤で示したものです。
ページ全体をサーバーでレンダーするのではなく、一部のコンポーネントはクライアントにレンダーさせていることがわかります。

Server ComponentがSuspenseするところ

また、React Server ComponentsはConcurrent Modeに完全対応し、Concurrent Modeを前提として動作します。
図は、データを取得するまでスケルトンスクリーンを表示しているところを録画したものです。
ここで取得しているのはサーバーでレンダーされているコンポーネントで、取得が完了する前に他のコンポーネントをレンダーしてクライアントに送信しています。
取得が完了したら、その部分だけ遅れてレンダー結果をクライアントに送信しています。
この仕組みは、Reactの実験的機能として公開されているConcurrent Modeで実現されています。

Server / Shared / Client Components

React Server Componentsは、コンポーネントを三種類に分類します。

Server Components

Server Componentsはサーバーでのみレンダーされるコンポーネントです。
拡張子を .server.js とするとServer Componentとして解釈されます。

Server Componentsは以下の制約を持ちます。

  • ステートを持てないため、useStateやuseReducerは使えません。
  • リレンダーが走らないので、ライフサイクルフックであるuseEffectやuseLayoutEffectは使えません。
  • クライアントとは分離された環境でレンダーされるため、useContextは使えません。
  • サーバーでレンダーされるため、ブラウザでのみ利用可能なAPI(DOMやWeb API)は利用できません。
  • 関数はシリアライズできないため、次に説明するClient Componentsに関数を渡すことはできません。

Server Componentは、Client Componentを子コンポーネントに持つことができます。

Client Components

従来のクライアントでのみレンダーされるコンポーネントは、Client Componentsと呼ばれます。
従来のコンポーネントですが、拡張子を .client.js とする必要があります。
ステートを持ったり、イベントをハンドルしたり、Web APIを利用したりする必要がある場合は、Client Componentにします。

Clinet ComponentではServer Componentsをimportできないという制約があります。
これは、Server Componentsのレンダーに必要なサーバーへのリクエストのせいでパフォーマンスが低下してしまうことを防ぐためです。

ただし、Server ComponentがClient Componentに対して、childrenなどのpropを通してServer Componentsを渡すことはできます。

例えば、以下のようなコードは正しく動作します。サーバーは、ServerComponentBをレンダーした結果であるJSXを、ClientComponentのprops.childrenに渡すので、ClientComponentから見ると、ただのJSXが渡ってきているようにみえるためです。

// ✔ Can
import ClientComponent from './ClientComponent.client.js'
import ServerComponentB from './ServerComponentB.server.js'
const ServerComponentA = () => {
  return (
    <ClientComponent>
      <ServerComponentB />
    </ClientComponent>
  )
}

しかし、以下のようなコードはコンパイルエラーとなります。

// ✗ Cannot
// Cannot import inside of a client component
import ServerComponentB from './ServerComponentB.server.js'
const ClientComponent = () => {
  return (
    <div>
      <ServerComponentB />
    </div>
  )
}

Shared Components

Shared Componentsは、サーバーとクライアントのどちらでもレンダリング可能なコンポーネントです。
Server Componentから呼ばれた場合はServer Componentとして、Client Componentから呼ばれた場合はClient Componentとして振る舞います。
拡張子は .js です。

Server Componentとしても、Client Componentとしても振る舞うため、両方の制約を持ちます。ステートは持てませんし、Server Componentをimportすることもできません。

使い分け

以上三種類のコンポーネントは、以下のようにして使い分けることができると考えました。(あくまで現時点での私の考えであり、今後生まれるベストプラクティスとは異なることがあります。)

  • 基本的に、パフォーマンス有利なServer Componentにすることを考える。
  • ステートが必要だったり、イベントをハンドルしたかったりなど、Server Componentの制約に引っかかる場合は、Client Componentにする。
  • Client Components内でも使いたいServer Componentは、Shared Componentにする。

共通コンポーネントは基本Shared Componentにすることになると考えています。

恩恵と特徴

React Server Componentsがもたらす恩恵は、パフォーマンス面が主です。

バンドルサイズの減少

Server Componentsのコードはクライアントがダウンロードするバンドルに含まれません。
クライアントはレンダーした結果のJSXを受け取るだけで、リレンダーが必要な場合は再度サーバーにレンダーリクエストします。
そのため、ユーザーのインタラクションに反応しないような大部分のコンポーネントを、クライアントのバンドルから取り除くことができます。

これによって、通信にかかるコストと、コードのパースにかかるコストを減少させることができる他、Virtual DOMにマウントされるコンポーネントが減少するためメモリ使用量が低下することが期待できます。

データフェッチにかかる時間の減少

Server Componentsはデータのあるサーバー上で実行されるため、データに直接アクセスできることがあります。APIを介したとしても、レイテンシーは大きく抑えられるでしょう。
もちろん、レンダーリクエスト分のレイテンシーは必要となりますが、複数のデータを必要とする場合には特にパフォーマンスが改善することがあります。

例えば、「記事リストの取得」と「記事のメタデータの取得」が異なるAPIで用意されているとします。
データは表示するコンポーネントが取得する責務を持つ、と考えて以下のような実装をしたとき、記事リストの取得が完了した後にメタデータの取得が始まることになります。

const ArticleList = () => {
  const articles = fetch('/path/to/articles');
  return (
    <ul>
      {articles.map((article) => (
        <li>
	  <ArticleItem article={article} />
	</li>
      )}
    </ul>
  );
}

const ArticleItem = (article) => {
  const meta = fetch(`/path/to/meta/${article.id}`;
  return <div>{meta.name}</div>;
}

このような、一つのフェッチが完了した後に別のフェッチが開始するような設計はアンチパターンで、RFCにおいてWaterfallと呼ばれています。
Server Componentsではフェッチにかかる時間が短いため、Waterfallの影響が小さくなります。
特に、Server ComponentからDBに直接接続できるような場合は、このような設計のほうが責務の設計がすっきりしていてより良いと判断できると思います。

Code Splittingの自動化

Server Componentsのレンダーが終了した時点で、必要となるClient Componentsがどれかというのが判明します。
React Server Componentsでは、自動でClient Componentsを切り分けてバンドル化し、必要となるもののみをクライアントに指定してダウンロードさせます。

Server Componentsのレンダリング結果

画像は、クライアントに送られるServer Componentsのレンダリング結果の中身ですが、1行目と2行目、5行目で、必要となるClient Componentとチャンク名が送られています。
クライアントは、まだ送られてきていないチャンクのみをサーバーにリクエストします。

レンダー結果のDOMへの更新は最小限

Server Componentsが返すレンダリング結果は、HTMLではなくReactコンポーネントです。
そのため、Reactは既にあるVirtual DOMに自然にマージすることができます。
変更されたDOM要素のみが更新されるため、更新されなかったDOMの状態(フォーカスなど)は失われませんし、マウント済みのClient Componentsの状態が失われることもありません。

SSRとの共存

さて、ここまでReact Server Componentsを解説してきましたが、「既にコンポーネントをサーバーでレンダーする技術はあるじゃないか」と疑問をお持ちの方もいらっしゃるかもしれません。
Next.jsなどで実現できるSSRは、サーバー上でHTMLにレンダーして初期表示を速くする技術です。最初にクライアントに送信される文書の時点で、最初の画面が既にレンダーされているため、SEOにも効果があります。その後Reactがクライアントで実行されると、すべてのコンポーネントがマウントされます。

これとは逆に、React Server Componentsの場合だと、最初に送信される文書の段階では何もレンダーされていません。動的にmetaタグを生成することはできませんし、初期表示も速くならないでしょう。しかし、マウントするコンポーネントの数はかなり減らすことができます。更に、Concurrent Mode対応です。

このように、React Server ComponentsとSSRの担当領域は異なるのですが、組み合わせることができます。
組み合わせることで、SSRによって初期表示を速くしつつ、React Server Componentsによってバンドルサイズを最小限にすることができます。

レンダリングの流れ

Next.jsのReact Server Componentsデモのレンダリングの流れを図に表してみました。

Server Componentsのレンダリングの流れ

デモのレンダリング部分の概略は以下のようなコードとなります。

// pages/index.js
import Root from '../components/Root.client'
export default function Index() {
  return <Root />
}

// Root.client.js
import { useServerResponse } from './Cache.client'
export default function Root() {
  return (
    <Suspense fallback={null}>
      <Content />
    </Suspense>
  )
}
function Content() {
  const [location, setLocation] = useState({
    selectedId: '',
  })
  const response = useServerResponse(location)
  const root = reponse.readRoot()
  return (
    <LocationContext.Provider value={[location, setLocation]}>
      {root}
    </LocationContext.Provider>
  )
}

// Cache.client.js
import { createFromFetch } from 'react-server-dom-webpack'
export function useServerResponse(location) {
  const key = JSON.stringify(location)
  return createFromFetch(
    fetch(endpoint + '/api?location=' + encodeURIComponent(key))
  )
}

言葉にすると、以下のような流れとなります。

  1. クライアントは、pages/index.js のレンダリングを行う。
  2. クライアントは、Root.client.js が実行する useServerResponse フックで、APIに対して初期LocationオブジェクトでAppのレンダリングをリクエストする(ここでサスペンドする)。
  3. サーバーは、APIで App.server.js のレンダリングを行い、pipeToNodeWritable で結果をストリームで返す。
    • Client Componentsはレンダーせずに、必要となるClient Componentリストに追加される。
    • Server Componentがサスペンドした場合、直近のSuspenseのfallbackが一度返される。
    • サスペンドがresolveし次第、サスペンドした部分のレンダリング結果を同じストリームで返す。
  4. クライアントは、送られてきた結果をレンダーし、Client Componentsもレンダーする。

ここまでで、構成されたコンポーネントツリーは以下のようになります。
Server Componentsのレンダリングに必要なデータをContextで管理するため、Server Componentsの上位(図では下)にClient Componentがあります。
ユーザーのインタラクションによってServer Componentsのリレンダリングが必要となった場合、Client ComponentsはLocation Contextの値を変更することでリレンダリングを発生させます。

React Server Componentsのコンポーネントツリーの概略

Server ComponentとClient Componentへの分離

ここまででReact Server Componentsの動作を見てきました。ここからは、デモを触った中で面白く感じた点をご紹介します。

デモアプリでは、以下のような機能を持つコンポーネントがあります。

  • 本文の最初を抜粋表示するアコーディオンパネル
  • 抜粋のためにMarkdownをパースする
  • フォーマットされた日時を表示する

このコンポーネントの動作

このコンポーネントは、アコーディオンパネルの機能を持つためにClient Componentにする必要がありそうです。しかし、Markdownをパースするために、できればパースライブラリをクライアントにダウンロードさせたくありません。
そこで、アコーディオンパネルの部分をClient Componentとして切り出して、その他のMarkdownのパースや日時のフォーマットの部分をServer Componentとします。
こうすることで、ライブラリをクライアントにダウンロードさせずに、インタラクティブ要素も実現することができました。

分離後の概略

Concurrent Mode対応のfetchライブラリ

デモでは、react-fetchというReact公式のfetchライブラリが使われています。
このライブラリはまだexperimentalでnpmに公開されていませんが、Concurrent Modeの理想的なfetchを実現しています。
Client Componentsでも動作するため、React Server Componentsとは直接関係ありませんが、興味がある方はソースコードを読んでみると面白いと思います。

import { fetch } from 'react-fetch
export default function NoteList({ searchText }) {
  const notes = fetch(endpoint + '/api/notes').json()

  return (
    <ul className="notes-list">
      {notes.map(note => (
        <li key={note.id}>
          <SidebarNote note={note} />
        </li>
      ))}
    </ul>
  )
}

fetch関数をコンポーネント内部で直接呼んでいることがわかります。従来のReactではこの書き方はできませんでした。
このfetch関数は、以下のような特徴によってConcurrent Modeに対応します。

  • リクエストを送信した後、そのPromiseをthrowするため、コンポーネントはサスペンドする。
  • react-fetchは、URLをキーにPromiseをキャッシュする。
  • Promiseがresolveして再レンダリングが走ったとき、キャッシュしていたPromiseを参照し、resolveしていたら値を返す。

これによって、従来のようにデータを表示するコンポーネントにデータを取得する責務も持たせつつ、データが未達の間どのように表示するかということを上位のコンポーネントに任せることができます。
もちろん、表示するコンポーネントをラップするコンポーネントを作ることで、fallbackをどうするかという責務を持つこともできます。

現状のreact-fetchはURLをキーにキャッシュしてしまうため、クライアントでは再リクエストを行うことができません。
再リクエストを送るには、URLにリクエストごとに異なる余分なクエリをつける方法が考えられます。これは、RecoilのQuery Request IDと似たものです。
もしかしたら、今後キャッシュをパージするAPIが生えるかもしれません。

まとめ

React Server Componentsは、Reactアプリケーションをさらに高速化する新しい技術です。
今後Concurrent Modeが登場した後に、Reactに組み込まれると思います。

Concurrent Modeの学習にもなるため、ぜひ皆さんの手でデモを触っておくことをおすすめします。
デモはReact版と、Next.js版が公開されています。Next.js版はまだSSRとの組み合わせが微妙な感じがしますが、どちらもおすすめです。Next.js版を試したい場合、(僕のforkリポジトリ](https://github.com/G4RDS/next-server-components)で軽いtypoの修正とsleep APIの追加をしているので、そちらをcloneしていただくのもおすすめです。

React Server Componentsについてより詳しく知りたい方は、RFCを読んで頂くのがおすすめです。

最後まで読んでいただき、ありがとうございました!

Links

Discussion