🔼

【Next.js】RSCとクライアントコンポーネントを改めて理解する

2024/01/25に公開

はじめに

最近、以下のディスカッションを目にしました。

https://github.com/vercel/next.js/discussions/46795

そこで、「RSCやクライアントコンポーネントについて、概要やメリット・デメリットくらいしか知らないな・・・」と思ったので、今回、学習した内容をまとめてみました。

この記事で以下の内容が理解できるかと思います。

  • ReactとNext.jsについて
  • App Routerとは
  • RSC(React Server Component)とは
  • RSCとクライアントコンポーネントの違い
  • RSCとクライアントコンポーネントのレンダリングについて(SSRとCSRについて)

前提

まずは、前提知識として必要なので、以下の内容を解説します。

  • Reactのレンダリングについて
  • Next.jsのレンダリングについて

ReactとNext.jsについて

ReactとはUIを作ることに特化したJavaScriptライブラリです。
https://react.dev/

コンポーネントを宣言的に定義することでUIを簡単に構築することができます。

Next.jsとはReactのフレームワークです。
https://nextjs.org/
簡単に言うとReactの機能を拡張して、Reactをより使いやすくしたものです。

Reactのレンダリング(CSR)

Reactで実装したアプリケーションをSPA(シングルページアプリケーション) と呼びます。

このSPAの課題の一つに初期表示が遅いというものがあります。
初期表示が遅い理由はReactなどのSPAアプリケーションはすべてCSR(クライアントサイドレンダリング)でレンダリングを行うためです。

CSRとは、ブラウザ上でJacaScriptを実行してDOMを生成し画面を表示させるレンダリング方法です。
CSR

つまり、様々なライブラリなどを含んだReactアプリケーション内のコードが実行されて初めて画面が表示されるということです。

ただ、Reactは仮想DOMという技術を用いて、レンダリング時にDOMの差分を検知して、差分が合ったDOMのみ更新するので、初回表示以降のレンダリングは早いです。
あくまで、初回表示が遅いという問題があると認識してください。

より詳細なReactのレンダリングについて知りたい方は以下のドキュメントを参照してください。

https://ja.react.dev/learn/render-and-commit

SSR(サーバーサイドレンダリング)

上記のSPAの初期表示が遅いという問題を解決する方法がSSR(サーバーサイドレンダリング) です。

SSRでは、アプリケーションの全てをJavaScriptで描画するのではなく、あらかじめサーバーで静的はHTMLを生成しておくレンダリング方法です。
要するに、SPAの最初に表示される画面のHTMLだけサーバーでレンダリングしておいて配信するということです。

SSR

  • ハイドレーション: サーバー側でレンダリングされたHTMLに対してJavaScriptをクライアントで紐付ける作業のこと(HTMLに紐づいたJavaScriptを実行してイベントリスナなどインタラクティブな操作ができるようにする作業)

これにより、SPAの課題であった初期表示を高速化することができます。
UXはもちろん、サーバー側で生成したHTMLにはメタ情報も含めることができるためSEOにも効果があると言われています。
(CSRではJavaScriptが実行されて初めてHTMLが生成されるため、クローラーがダウンロードするHTMLにメタ情報が含まれていない場合があるためSEOに不利と言われています)

ただ、このようなSSRを実装したアプリケーションはReactでの実装では実現できません。
そこで、でてくるのがNext.jsというわけです。
ちなみに、Next.js(Pages Router)でSSRを実装する場合は、getServerSidePropsという非同期関数をエクスポートする必要があります。

Next.js

現在、Next.jsには以下2つの実装方式(ルーティング方式)がありますが、レンダリングの種類については変わらないので、各レンダリング方式を解説したあと、ルーティング方式の違いを解説します。

  • Pages Router
  • App Router

Next.jsのレンダリング方式

前提として、Next.jsではすべてのページをプリレンダリングします。
プリレンダリングとは、Next.jsが各ページのHTMLをクライアント側のJavaScriptで生成するのではなく、あらかじめ生成しておくことです。
要するに、プリレンダリングはSSRと同じということです。

そこで、Next.jsのレンダリング方式の種類に話を移しますが、Next.jsにはSSRを含め以下4つのレンダリング方式があります。
CSR以外は全てプリレンダリングとなります。
※ 実装方法はドキュメントや記事を参照してください。ここでは概念等の解説にとどめます。

  • SSR
  • SSG
  • ISR
  • CSR

SSRとCSRは先程、解説したとおりなので、解説は割愛し、SSG・ISRの解説をします。

SSG(静的サイト生成)

SSGはアプリケーションをビルドした際にHTMLを生成するレンダリング方式です。
SSRと違い、「このページを表示したい」などのリクエストより前にHTMLが生成されているレンダリングということです。

SSGで生成されたHTMLはリクエストごとに再利用され、CDNでキャッシュすることも可能です。

Next.jsでは、データが存在しないページ(静的HTML)だけでなく、ビルド時にデータを取得・登録したHTMLを生成することもできます。
Pages Routerでは、getStaticPropsgetStaticPathsを使用して、上記の内容を実現することができます。

SSGではリクエストごとにサーバーがHTMLをレンダリングする必要がない(既にHTMLが生成されている)ので、SSRと比較して、レンダリングが非常に高速です。
そのため、基本的にはSSGの使用が推奨されています。

ISR(Incremental Static Regeneration)

SSGの場合、ビルドし直さない限りページを更新できないという問題が生じます。
これを解決するのがISRというレンダリング方式です。

ISRの仕組みは、まず、ページにリクエストが来た際にビルド済みの静的ページを表示しつつ、裏で再レンダリングを行います。
そして、再レンダリングが完了し次第、新しく生成したページを表示します。
これがISRの仕組みです。

要するにSSGとSSRのいいとこ取りができるレンダリング方式という位置づけになります。(SSGによる高速レスポンスを実現しつつ、ある程度のデータ整合性を担保できる)

ある程度のデータ整合性といったことについて、解説します。
ISRの場合、ページが再生性され、最新のデータを取得し表示するためには2回目のリクエストが必要dす。

ページにリクエストが来た際にビルド済みの静的ページを表示しつつ、裏で再レンダリングを行います

上記の記述がポイントですが、まず、リクエストが来たらビルド済みのページを表示します。
(ここまではSSGと同じ動きです)
ここで、ページ再生成のタイミングだった場合に裏で再レンダリングが実行されます。
つまり、ページ再生成のタイミングを超えてリクエストした場合でも、とりあえずビルド済みのページを表示させるので初回のリクエストでは更新前のデータが表示されます。

そして、2回目のリクエストでは、レンダリングが完了したビルド済みの最新ページを表示するという動きをします。
一番、覚えておいてほしいことは2回目のリクエストでないとページが最新ではなく1つ前のページを表示するという部分です。
そこだけ注意点ですので、「更新されないぞ?!」とハマらないようにしてください。

ちなみに、Next.js(Pages Router)では、getStaticPropsの戻り値にrevalidateというパラメータを追加することで実現できます。

このrevalidateにはページの再生成の間隔を指定します。
例えば、revalidate: 60とすることで、60秒ごとにページを再生成することができます。

Pages RouterとApp Routerの違い

Next.jsのルーティング方式による違いとしては大きなものとして2つ挙げられます。

  1. ルーティングの違い
  2. サーバーコンポーネント(RSC)

本記事ではRSCの部分のみ解説します。
詳しいことは公式か以下の記事が簡潔なので、そちらを参照してください。
https://zenn.dev/collabostyle/articles/7377d383430bf3

RSCはApp Routerのベースとなる技術です。
そのためApp Routerを理解するためにはRSCの理解は必須です。

前提として、App Routerでは全てのコンポーネントがデフォルトでRSCとなりました。
そしてクライアントコンポーネントとして扱いたい場合には、ファイルの先頭にuse clientディレクティブを宣言するよう変更が加えられました。
要するに、必要な箇所だけクライアントで実行させることを基本的な考えとしているわけです。
この点を前提知識として持っておいてください。

ここで、ようやく本題のRSCやクライアントコンポーネントについて解説します。

RSC(React Server Component)

まず、「RSCとはなにか」という部分ですが、一言でいうとコンポーネント単位でレンダリング方式をSSRまたはCSRに分けることができる技術です。
要するに、コンポーネントがレンダリングされる場所がサーバー側なのかクライアント側なのかということを識別する技術です。

ここで、サーバー側でレンダリングされるコンポーネントを「サーバーコンポーネント」といい、クライアント側でレンダリングされるコンポーネントを「クライアントコンポーネント」といいます。

このRSCには以下の3つの利点があります。

  • 配信バンドルサイズの軽量化
  • 非同期にレンダリングできる
  • バックエンドに直接アクセスできる
    ※ RSCを使用すれば、無条件にバンドルサイズが現象するわけではないようです。(以下の記事を参照)

https://qiita.com/uhyo/items/06b0cd7292256f66d7b7

逆にデメリットとしては、以下の2つがあります。

  • ブラウザAPIが使えない
  • useEffectなどReactのフックが使用できない

デメリットに上げたようにブラウザAPIやフックを利用してユーザーとのインタラクティブな操作を実装したい場合は、クライアントコンポーネントで行う必要があります。
それには、前述したようにuse clientディレクティブをファイルに宣言する必要があります。

次に先程挙げたRSCの利点や特徴の解説をします。

配信バンドルサイズの軽量化

サーバーコンポーネントは処理結果だけをブラウザに送信します。
例えば、日付処理のための外部ライブラリをインストールして利用したとしても、そのライブラリのソースコード自体はブラウザに送信されません。これによってブラウザに送信するファイルの容量が削減され、パフォーマンスが向上します。

RSCのレンダリング

よくある間違いに、「SSRとRSCのレンダリングは同じ」というものがあります。
実際には、SSRとRSCのレンダリングには違いがありますので、それを解説します。

まずは、SSRについてですが、前述したように、SSRは以下のようにレンダリングします。
SSR

  1. サーバーで全体をレンダリングし、HTMLを生成
  2. 生成したHTMLをDOMに反映させ、クライアント側で表示
  3. JavaScriptをハイドレーション

サーバー側でHTMLを生成することで初期表示を速めることが特徴でしたよね?

一方、RSCでは以下のようにレンダリングされます。
RSC Rendering

1.サーバー側でサーバーコンポーネントをレンダリングする
2.サーバーコンポーネントのHTMLとクライアントコンポーネントのJavaScriptをクライアント側に送信する
3.クライアントコンポーネントをレンダリング
4.生成したHTMLをDOMに反映し、画面表示

大きな違いは以下の3点です。

  • SSRの場合は初期表示が速い・SEOに有利
  • RSCの場合はサーバーとクライアントでそれぞれのコンポーネントがレンダリングされる
  • SSRのほうがクライアントに送信されるJavaScriptの量が多い

SSRとRSCのレンダリングを比較しましたが、組みあせて使用することが可能です。
(実際、そのような実装のほうが多いです)

組み合わせた場合は、以下のようになります。
SSR・RSC

1.サーバー側でサーバーコンポーネントをレンダリングする
2.サーバー側でクライアントコンポーネントもレンダリングする(SSR時の挙動)
3.生成したサーバーコンポーネントとクライアントコンポーネントのHTMLをクライアント側に送信してDOMに反映
4.初期表示
5.クライアント側にクライアントコンポーネントのJavaScriptを送信し、ハイドレーション

このようにSSRとRSCを組み合わせることで、初期表示を速めつつ、クライアント側に送信するJavaScriptの量を抑えることができます。

非同期レンダリング

React18でサーバーサイドレンダリングを強化するために非同期SSRという新機能が追加されました。

これにより、コンポーネント単位でSSRが行えるようになりました。
つまり、レンダリングに時間のかかるコンポーネントをサーバー側で処理している途中に画面を表示しておくことができ、また、ユーザーはその画面を操作することができます。

以前のReactでSSRを行うとページ全体のレンダリングが完了するまでブラウザへとHTMLを送信することができませんでした。

React18ではSuspenseというコンポーネントで区切られた単位で非同期にSSRを行います。ページ全体のレンダリングが完了していなくてもブラウザ上での表示を始めることができます。

そしてレンダリング中のコンポーネントはSuspenseの引数で指定されたローディングを表示しておき、レンダリングが完了次第、置き換えられます。
これをストリーミングHTMLといいます。

また、非同期SSRをしつつ、先に読み込まれて画面に表示された部分をインタラクティブに操作することもできます。
要するにハイドレーションを段階的に行うということです。
これもReact18で実行できるようになった機能の1つです。

このような段階的なハイドレーションを選択的ハイドレーションと呼びます。

実装にすると以下のようになります。
SuspensePostListを囲むことで、データが取得できるまではSpinnerでローディング表示しつつ、Sidebarは表示して、かつインタラクティブな操作もできるようにしています。
取得が完了するとPostListが表示されます。

page.tsx
import { Suspense } from "react";
import { Sidebar } from '@/components/Sidebar';
import { Spinner } from '@/components/Spinner';
import { PostList } from '@/components/PostList';

const Home = async () => {
  const res = await fetch('/api/posts')
  const posts = await res.json()
  
  return (
    <div>
      <div>
        <Sidebar />
      </div>
      <Suspense fallback={<Spinner />} >
        <div>
	  <PostList posts={posts} />
	</div>
      </Suspense>
    </div>
  )
}

export default Home

以上がRSCの解説になります。

use clientディレクティブ

次にuse clientディレクティブについて解説します。
よくある間違いとしては「use clientを付けるとクライアントコンポーネントになる」というものです。

ここから先は、クライアントコンポーネント側の解説をします。
以下のディスカッションを参考にしているので、参照してみると理解が深まると思います。
https://github.com/vercel/next.js/discussions/46795

use clientの意味

use clientディレクティブは、そのファイル内のコンポーネントがクライアントサイドでのみ実行されることを示します。これは、そのファイルがサーバーコンポーネントとクライアントコンポーネントの境界を表すという意味です。

これが何を意味しているのかというと、RSCのコンポーネント間で通信に扱えるデータに違いがあることを意味しています。
つまり、propsで渡せるデータに違いがあるということです。

扱えるデータの違い

結論をいうと、ネットワーク(サーバー)を介したデータのやり取りはシリアライズ化されたデータで通信する必要があるということになります。

use clientディレクティブがある場合、そのファイル内の子コンポーネントのプロパティはシリアライズ可能でなければならず、これはネットワークを介してデータを送信するための要件です。

シリアライズ可能なデータは、JSONとして表現できるデータ(オブジェクト、配列、文字列、数値、真偽値、null、undefined)を指します。
これら以外はシリアライズ化できないデータになります。

例えば、オブジェクトや配列はJSONとして扱うことができるので、シリアライズ化されたデータと言えます。
一方、関数は、オブジェクトとして扱うことはできますが、JSONオブジェクトとして表現することはできません。そのため、関数はシリアライズ化できないデータとなります。

境界内ではuse clientは一度の宣言にすべし

ディスカッションの例では、MessageInputuse clientを宣言しているためProps must be serializable for components in the "use client" entry file, "setMessages" is invalid.というエラーが起きます。

parent.tsx
"use client";
import { Message } from "@prisma/client";
import React, { useState, useEffect } from "react";
import MessageInput from "./MessageInput";

type Props = {
  initialMessages: Message[];
};

export default function MessagesContainer({ initialMessages }: Props) {
  const [messages, setMessages] = useState(initialMessages);
  return (
    <div className="flex flex-col items-center justify-center h-full">
      <MessageInput messages={messages} setMessages={setMessages} conversationId={""} />
    </div>
  );
}
child.tsx
"use client";
import { ConversationRole, Message } from "@prisma/client";
import React, { useState } from "react";

type Props = {
  messages: Message[];
  setMessages: (message: Message[]) => void;
  conversationId: string;
};

// The error exists on `setMessages` here
export default function MessageInput({ messages, setMessages, conversationId }: Props) {
  const [input, setInput] = useState("");

  return (
    <div>
      <input
        onKeyDown={async (e) => {
          if (e.key === "Enter") {
            setMessages(newMessage);
            setInput("");
          }
        }}
        value={input}
        onChange={(e) => setInput(e.target.value)}
      />
    </div>
  );
}

この場合、MessageInputからuse clientを削除すると正常に動作します。
なぜなら、親コンポーネントでuse clientを宣言しており、そこでインポートされているので、再度MessageInputに対してuse clientを宣言しなくてもクライアントコンポーネントとして認識されるからです。

つまり、use clientディレクティブ内でインポートされたものは親コンポーネント内のスコープで実行されるためクライアントコンポーネントとして認識されます。そのため、シリアライズされていない関数をpropsで渡すことができます。

つまり、use clientはサーバーコンポーネントとの境界なので、「このファイルにインポートしたものはクライアントコンポーネントとして扱ってください」という宣言になります。

したがって、MessageInputuse clientを宣言すると、「このファイルにインポートしたものはクライアントコンポーネントとして扱ってください」と子コンポーネントでも宣言することになります。
そのため、MessageInputにインポートされたものはクライアントコンポーネントとして認識されます。ここまでは正しい挙動です。

これをuse clientディレクティブを宣言している親コンポーネントでインポートすると「サーバーコンポーネントとの境界はこのファイルのはずなのに、インポートしたものコンポーネントにも境界がある」とNext.jsは認識します。

つまり、子コンポーネントのファイル新たなクライアントコンポーネントの境界を作成しようとするため、Next.jsが混乱を起こすため、エラーとなるわけです。

まとめると、この問題は親子でuse clientを宣言するために起きます。
サーバーコンポーネントとの境界ということを知っていれば、親コンポーネントのみの宣言にできるはずです。
なぜなら、use clientを宣言したファイルでインポートされたコンポーネントはクライアントコンポーネントとして認識されるからです。

RSCは難解な部分もありますが、このようなことを理解しておくことが実装者として重要なのかなと思いました。

おわりに

Next.jsは非常に多機能なうえ、かなり開発しやすいですが、概念の理解を怠ると、開発に手詰まりを起こし、エラー沼にハマるということが今回で理解できました。

まだまだ、使っていない機能や追えていない機能もあるので、React含めて学習していきたいです。

参考文献

https://github.com/vercel/next.js/discussions/46795
https://react.dev/
https://nextjs.org/
https://ja.react.dev/learn/render-and-commit
https://zenn.dev/collabostyle/articles/7377d383430bf3
https://qiita.com/newbee1939/items/7ce919f9a1a7153582b8
https://qiita.com/uhyo/items/06b0cd7292256f66d7b7

Discussion