React Server Component の Isomorphism について解説する

6 min read読了の目安(約5700字

Next.js + React Server Component のリファレンス実装が出たので、手元で動かしながら理解したメモ。

vercel/next-server-components: Experimental demo of React Server Components with Next.js. Deployed serverlessly on Vercel.

これを書いてるモチベーションとして、Twitter を見る限り React Server Component のことを 「ただのサーバーサイドへの先祖返り」とか「SSR 結果を dangerouslySetInnerHtml してるだけでは?」みたいな反応があったので、そのへんの誤解を解きたい。

Introducing Zero-Bundle-Size React Server Components – React Blog

tl;dr

サーバーでのレンダリング結果を JSON シリアライズして、クライアントから要求できる。サーバー側での React Element の組み立ての過程にサーバー(Node.js)の API が使えるので、都度 API を実装する必要なく、RPC の中で隠蔽できる。一種の Isomorphism (クライアント・サーバー同型写像)。

これはつまり、サーバーで実行、更新される非同期コンポーネントであり、比較対象は Rails Hotwire, Phoenix LiveView の非同期 View などになると思われる。

React Server Component の目的

そもそも手段は一つだが、目的が二つ混ざっているので、これを整理する。

  • ビルドサイズを重くなってしまうライブラリをサーバーサイドで実行して結果だけ返す。例えば marked や prettier、babel など
  • RPC によって Client と Server で API の呼び出しを自然に隠蔽する Isomorpish を実現する

従来の React Element は関数参照などを扱うため、 JSON シリアライズができない構造体だったが、React Server Component は React Element のシリアライズしたものを、クライアントから要求できる。その代わり、JSON シリアライズできない関数などは渡せない。イベントハンドラを扱いたい場合は、 Server Component から Client Component を含んでレンダリングして、クライアント側でそれを Hydration する。

next.js 拡張のフルスタックフレームの blitz などでは、似たような実装がある。

blitz-js がどうやってサーバー上の関数のクライアントでの呼び出しを実現しているのか、調査した

ただの SSR 結果の HTML を受け取るのとの違い

*.server.js はサーバーサイドでレンダリングされるが、その際に *.client.js のクライアント側のコンポーネントも含めることができる。クライアントは純粋な HTML ではなく、React Element として受け取るので、受け取った際に、 Hydration が行われて、JS のロジック注入で、React Component としてクライアントで動的に振る舞う。

Server Component である components/App.server.js をみると、その様子がわかる。

App.server.jsSearchField.client をマウントしている。

import React, { Suspense } from 'react'
import SearchField from './SearchField.client'
import Note from './Note.server'
import NoteList from './NoteList.server'
import AuthButton from './AuthButton.server'
import NoteSkeleton from './NoteSkeleton'
import NoteListSkeleton from './NoteListSkeleton'

export default function App({ selectedId, isEditing, searchText, login }) {
  return (
    <div className="container">
      <!-- 中略 -->
          <section className="sidebar-menu" role="menubar">
            <SearchField />
            <AuthButton login={login} noteId={null}>
              Add
            </AuthButton>
          </section>
          <nav>
            <Suspense fallback={<NoteListSkeleton />}>
              <NoteList searchText={searchText} />
            </Suspense>
          </nav>
        </section>
        <section className="col note-viewer">
          <Suspense fallback={<NoteSkeleton isEditing={isEditing} />}>
            <Note login={login} selectedId={selectedId} isEditing={isEditing} />
          </Suspense>
        </section>
      </div>
    </div>
  )
}

入力として JSON を送って、関数ハンドラなどを扱わない React Element を返している。

実現される isomorphism

例えば Rails でも app/views/index.erb の取得の際に、 ActiveRecord を叩いて SQL の実行結果を View に含んでいる。 それと同様に React Server Component の Element 組み立ての過程は Node.js で行われるので、サーバーサイドの機能をフルに使える。

ちょっと前に Next.js の getServerSideProps で Prisma ORM を叩くサンプルを書いたが、 Server Component では hooks でラップした上で似たような書き味になると思う。

import type { GetServerSideProps } from "next";
import prisma from "../lib/prisma";

type Props = {
  count: number;
};

export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
  const count = await prisma.user.count();
  return {
    props: {
      count,
    },
  };
};

export default function Index(props: Props) {
  return <div>user count: {props.count}</div>;
}

Next.js から Prisma ORM を利用する

最初のデモでは /react のエンドポイント、 next-server-component では /api で実装されていたが、RPC を一つ実装するだけで、参照系の API を実装する必要はなくなる。参照系のリソース参照は Server Component の組み立て過程に組み込まれているので、API としてクライアントに露出する必要がない。

とはいえ、更新系は別途実装する必要があり、更新が発生すると、 Server Component を再度取得し直して、その結果を React として差分更新する、といった実装を行う必要がある。

next-server-components/NoteEditor.client.js at master · vercel/next-server-components を読むと、Markdown を更新した際に、キャッシュを捨てて再取得することでプレビューし直すのが読み取れる。

これはつまり、サーバー上の state を使ってクライアントを更新する非同期 View であり、最初に述べたように、この比較対象は、 Rails Hotwire やそのネタ元であろう Phoenix LiveView になると思う。

hotwired/hotwire-rails: Hotwire is an alternative approach to building modern web applications without using much JavaScript by sending HTML instead of JSON over the wire.

Phoenix.LiveView — Phoenix LiveView v0.15.3

Hotwire と LiveView は JS を否定してこの実装になってるのに対して、 React Server Component は JS を積極的に活用して最適化やワークフロー改善を行った結果、このような実装になってる。Hotwire は全コネクションに対して Websocket で変更をプッシュするというワイルドな実装で、運用上の難を感じるので、React Server Component のほうが自分は伸びしろを感じる。やはりクライアント ・サーバーで同じ言語であるという利点が強い。

今後どのようになるか

現状のコードを読む限りは webpack のプラグインの使い方が難しかったり、専用のマニフェストの生成をサーバー実装から参照する必要があったりで、こなれていない。たぶんほとんどがボイラープレートになると思われるので、将来的に無設定で Node.js の http-server 上のエンドポイントとして動くコードが生成できるだろう。

これは完全に邪推だが、 Next.js でデモが出たのは、 React の SSR のサーバーの実装として一番使われているのが Next.js だったので、PHP が主流な Facebook 社内で開発していくより Next.js 開発元の Vercel と連携して開発していく、というメッセージだと思っている。その上で Facebook 社内でもあわよくば Server Component に切り替えたいのではないか? という気がする。experimental リポジトリでは react-pg といった postgres の pg のラッパーを用意しつつ、デモでは外部サーバーへの isomorphic fetch で外部リソースを参照しているように見せかけているのは、社内では PHP エンドポイントを叩きますよ、といったメッセージだと思っている。

しかし現状、Next.js 上で実装されているが API Route を使っているだけで、とくに Next.js としては組み込まれていない。 Next.js では無設定で使えて、単に getServerSideProps が Server Component に切り替わる、というのが、今後一番ありそうなシナリオだと思っている。

Next.js 以外では、webpack のビルド結果を Node.js コンテナでマウントしてデプロイ、といった風になるのではないだろうか。

この記事に贈られたバッジ