getServerSidePropsがわかれば'use client'がわかる
React Server Components (以下、RSC) は、Next.js App Router で先行導入されてから1年半以上の時間を掛けたのち、2024/12/05 に React 19 の一部としてリリースされました。
RSC は、コードを「サーバー」/「クライアント」の2つの環境に分割することが特徴であり、関連していくつかのマーカーが登場しています。
import 'client-only'
import 'server-only'
'use client'
'use server'
巷では、これらのマーカーの働きについて、理解できず混乱する人が多く見受けられます。
- 🤔
'use server'
を付けたら Server Component になると思ってた…- 正解: Server Component には使わない。Server Function (Server Action) になる。
- 🤔 「サーバー限定の処理」であれば、思考停止で
'use server'
を付けていいと思ってた…- ⚠️ Server Function は外部から自由に呼び出せるエンドポイントなので危険です![1]
- 🤔
'use client'
って、どれくらい気軽に使っていいの?- A. Button とか Input みたいな「プリミティブ」なコンポーネントで、内部で State, Ref, Context を使う場合は気にせず
'use client'
を使っていいよ
- A. Button とか Input みたいな「プリミティブ」なコンポーネントで、内部で State, Ref, Context を使う場合は気にせず
ズバリ、これらを、単なる「〇〇側の環境でのみ実行するコードのマーカー」 と考えていませんか?
結論: 各マーカーの意味合い
import 'client-only'
, import 'server-only
は単なる「〇〇側の環境でのみ実行」という意味のマーカー ですが、'use client'
, 'use server'
は、それらに加えて、「もう一方の環境にあるファイルが、このファイルを import したとき、『境界』として機能する」という意味があります。
このように考えると、混乱していたのがスッキリと解決するはずです!
import 'client-only', import 'server-only', 'use client', 'use server' それぞれの働きについて詳細な説明
-
import 'client-only'
- 記載したソースファイルは、クライアント環境でのみ実行可能
- サーバー環境で import するとビルドエラーが発生する
-
import 'server-only'
- 記載したソースファイルは、サーバー環境でのみ実行可能
- クライアント環境で import するとビルドエラーが発生する
-
'use client'
- 特別な「境界としての Client Component」のマーカー
- https://ja.react.dev/reference/rsc/use-client
- クライアント環境でのみ実行される
- いつ?→コンポーネントのレンダリング時に、
- クライアント環境のコンポーネント(Client Component)がこれを import することができる
- これは普通。従来のコンポーネントと同じ。
-
サーバー環境から(つまり、Server Componentが)これを import することができる
- このコンポーネントは、サーバー環境から Props としてデータを受け取ることができる(RSC Payload にシリアライズされる。対応データ型限定)
- クライアント環境のコンポーネント(Client Component)がこれを import することができる
-
'use server'
- "Server Function"のマーカー
- https://ja.react.dev/reference/rsc/use-server
- サーバー環境でのみ実行される
- いつ?→ブラウザ側から任意のタイミングで、
- クライアント環境がこれを import することができる
- この関数は、クライアント環境から引数を受け取って、結果を返すことができる(引数、返り値ともに RSC Payload にシリアライズされる。対応データ型限定)
- 「境界としてのClient Component」が Server Component から Props として受け取ることもできる
- Server Component の中にも直書きできる
- クライアント環境がこれを import することができる
…結論としてはこれだけですが、「環境」だとか「もう一方の環境との『境界』」だとか、具体的な例が無いとなかなか理解しづらいと思います。
そこで、この記事では、論点を絞り込んで 「'use client'
が『境界』として機能する」 とはどういうことなのか、身近な例として、
'use client'
という「境界」をまたぐ Server Component と Client Component の連携- Next.js Pages Router における
getServerSideProps
(およびgetStaticProps
) とページコンポーネントの連携
を比較することで理解を深められたらと思います。
前提知識: 「ハードナビゲーション」と「ソフトナビゲーション」
この記事では、説明を分かりやすくするために、「ハードナビゲーション」を無視して、「ソフトナビゲーション」時の挙動に焦点を当てます。なので、まずはこれらの用語をご存じない方のために簡易的に説明します。
まず、Pages Router / RSC を問わず、広い意味でいわゆる SPA (単一ページアプリケーション)と呼ばれるアプリケーションでは、ページの遷移に 「ハードナビゲーション」「ソフトナビゲーション」の2種類があります。
-
ハードナビゲーション
- SPA 以前からある、従来型のページ遷移です。
- 移動先の「新しいページ」のドキュメントそのもの(主に HTML 形式)をサーバーから取得します。
- SPA であっても、ブラウザのリロードボタンを押したとき、初めてサイトを訪れたときに発生します。
- また、
next/link
やreact-router
の Link を使わずにネイティブの<a>
タグを使ったときにも発生します。
- また、
-
ソフトナビゲーション
- SPA によって導入された、新しいタイプのページ遷移です。
- 「新しいページ」に移動したように見せかけながら、ドキュメントそのものではなく JSON 等の形式でのデータのみを取得して、JavaScript でページ内容を再構築します。
- この方式でナビゲーションするために、各種フレームワークやルーターライブラリから機能が提供されています。
- 例: Next.js の場合:
-
<a>
の代わりにnext/link
の Link コンポーネント - 手続き的な遷移のときには、
router.push()
-
- 例: Next.js の場合:
Next.js は、Pages Router, App Router もともに、
- ソフトナビゲーション時には、「JSON 等の形式でのデータのみを取得する」ことで画面遷移を速く・なめらかにして
- ハードナビゲーション時には、「1枚で完結した HTML をあらかじめ描画して返す」ことで、初期描画のスピードとクローラー向けのケアをする
という二兎を追うことが可能なように、いわゆる SSR や SG のための機能を備えています。
getServerSideProps
を使った場合の挙動
Pages Router で Pages Router は、われわれが用意した getServerSideProps
(および getStaticProps
)という関数を使って、上記のようなハード / ソフトナビゲーションの両方の最適化に対応できる仕組みになっています。
import { type GetServerSideProps } from "next";
import { CountDisplay } from "@/_common/counter/count-display";
// 「いまのアクセスが何番目か」を取得できるオブジェクト(注意: めっちゃ手抜きです!!)
const visitCounter = {
count: 0,
current() {
return ++this.count;
},
};
// getServerSideProps から CounterPage に渡す Props の型定義
type Props = { visitCount: number };
export const getServerSideProps: GetServerSideProps<Props> = async () => {
return { props: { visitCount: visitCounter.current() } };
};
// ページ本体。こちらは default export なので、自由な名前が付けられます。
export default function CounterPage({ visitCount }: Props) {
return (
<div>
<h1>訪問者カウント(Pages Router)</h1>
<CountDisplay value={visitCount} />
</div>
);
}
ハードナビゲーション よりも ソフトナビゲーション のフローのほうが「サーバー / ブラウザの境界」を意識しやすいので、意図的にソフトナビゲーションに焦点を当てて解説します。
ソフトナビゲーション時の処理の流れ:
ハードナビゲーション時の処理の流れはこちら
ソフトナビゲーション時には、ページの HTML 全体を取得するリクエストではなく、ページを更新するために必要なデータ(⭐ マークをつけたやつ)をリクエストします。
サーバー内で getServerSideProps
が実行されて、その返り値のデータは JSON 形式の文字列に変換(シリアライズ)されてブラウザに返ってきます。ブラウザはその JSON からデータを復元して、(あらかじめ用意した)ページコンポーネント(ここでは CounterPage
)レンダーします。
App Router で RSC, 'use client' を使った場合の挙動
App Router は、getServerSideProps
, getStaticProps
ではなく、RSC の仕組みを使って、ハード / ソフトナビゲーションの両方にそれぞれ最適化します。
例として、App Router を使って、敢えて Pages Router っぽい構成で作ったページをお見せします。
- src/app/counter-app/
-
page.tsx (
CounterPageServer
)- Next.js がページ本体として認識するファイル。
- これ自体は、
getServerSideProps
がやっていた処理だけをして、ページの描画はpage.client.tsx
に任せる
-
page.client.tsx (
CounterPageClient
)-
'use client'
付き。Pages Router でのCounterPage
に相当する働きをする。 - ファイル名は自由だが、わかりやすさの為にこうした。
-
-
page.tsx (
- src/_common/counter/
-
count-display.tsx (
CountDisplay
)- カウント表示用のコンポーネント。
'use client'
も何も付いていない。
- カウント表示用のコンポーネント。
-
count-display.tsx (
import { unstable_noStore } from "next/cache";
import { CounterPageClient } from "./page.client";
//「いまのアクセスが何番目か」を取得できるオブジェクト(注意: めっちゃ手抜きです!!)
const visitCounter = {
count: 0,
current() {
return ++this.count;
},
};
export default function CounterPageServer() {
unstable_noStore(); // 動的ページにするのに必要(next 15.1.0 デフォルト設定)
const visitCount = visitCounter.current();
return <CounterPageClient visitCount={visitCount} />;
}
"use client";
import { type FC } from "react";
import { CountDisplay } from "@/_common/counter/count-display";
type Props = { visitCount: number };
export const CounterPageClient: FC<Props> = ({ visitCount }) => {
return (
<div>
<h1>訪問者カウント(App Router)</h1>
<CountDisplay value={visitCount} />
</div>
);
};
ソフトナビゲーション時の処理の流れは、以下のようになります。
ハードナビゲーション時の処理の流れ
Pages Router のときと、ほとんど同じような構造になっていることに気がつくと思います。
Pages Router だと、⭐ のデータは { props: { visitCount: 1 } }
のような形であり、これが JSON にシリアライズされてブラウザに返されましたが、
App Router だと、⭐ のデータの中身は <CounterPageClient visitCount={visitCount} />
のような「コンポーネントのレンダー結果」そのものであり、これが RSC Payload という特別な形式にシリアライズされてブラウザに返されます。
Pages Router の「境界」と RSC の「境界」
getServerSideProps
と CounterPage
の間
Pages Router での「境界」は Pages Router の場合の getServerSideProps
/ CounterPage
の働きを見てみましょう。
// getServerSideProps から CounterPage に渡す Props の型定義
type Props = { visitCount: number };
export const getServerSideProps: GetServerSideProps<Props> = // 省略
export default function CounterPage({ visitCount }: Props) //省略
getServerSideProps
では、ブラウザからはアクセスできないサーバー側の情報を使って、カウンターに渡すべき数値を取得していました。画面の表示まではできませんが、そのために必要なデータを返り値として返却しています。
そのデータを受け取った CounterPage
がレンダーされることで、画面の表示内容が決定します。
getServerSideProps
を実行して、その結果を(「境界」を超えて) CounterPage
に渡すのは、Next.js 側の責務です。
'use client'
で宣言される
RSC の「境界」は App Router の場合、CounterPageServer
が、 getServerSideProps
の役割を受け継いでいます。CounterPageServer
のレンダー結果は、以下のようになります。
// RSC Payload に含まれるレンダー結果の情報を擬似的に表現したものです。
// 実際のコードではありません。
import { CounterPageClient } from "[project]/src/app/counter-app/page.client.tsx";
<CounterPageClient visitCount={1} />;
CounterPageClient
には 'use client'
によって「境界」であると宣言されているので、一旦はその中身を描画せずに「⭐ ここに <CounterPageClient visitCount={visitCount} />
って描画してください」と言い残して、後に任せます。Server Component の世界はここで終わりです。
具体的には、?rsc=
という形のリクエストに対して、サーバー側では CounterPageClient
の中身までは描画せず、レスポンスとして先ほどの「⭐ ここに <CounterPageClient visitCount={visitCount} />
って描画してください」を返してあげます。(このときに RSC Payload 形式にシリアライズされたデータが送信されます)
(ソフトナビゲーションの場合は、)このメッセージが RSC Payload 形式でブラウザに返ってきます。ブラウザ側では、CounterPageClient
が { visitCount: 1 }
のような Props を受け取って(従来のコンポーネントと同様に)レンダーされます。
(Pages Router)
getServerSideProps
を実行して、その結果を(「境界」を超えて)CounterPage
に渡すのは、Next.js 側の責務です。
という形だった Pages Router とは異なり、App Router では、ソースコード上では「単純に import してレンダーした」 ように見せかけて、実際には Next.js が裏側で「境界」を超えてデータを受け渡してくれる 仕組みが働いているのです。
Client Component はドミノ倒し的に伝染する
CounterPageClient
が他のコンポーネントを import して利用した場合には、それら全てが Client Component としてレンダーされます。
つまり、CounterPageClient
の内側は Client Component の世界、つまり「一旦 RSC のことを考えなくてよい世界」になります。RSC / App Router に慣れていない方も安心して過ごしてください。
たとえば、CounterPageClient
は CountDisplay
というコンポーネントを import して使っています。CountDisplay
には import 'client-only'
も 'use client'
も付いていませんが、Client Component の世界の中にいるので、これも Client Component としてレンダリングされます。
つまり、import の依存関係を伝って「ドミノ倒し」のように伝染していくイメージです。
CounterPageClient と CountDisplay それぞれのソースコードはこちら
"use client";
import { type FC } from "react";
import { CountDisplay } from "@/_common/counter/count-display";
type Props = { visitCount: number };
export const CounterPageClient: FC<Props> = ({ visitCount }) => {
return (
<div>
<h1>訪問者カウント(App Router)</h1>
<CountDisplay value={visitCount} />
</div>
);
};
import { type FC } from "react";
import styles from "./count-display.module.scss";
type Props = { value: number };
const formatter = new Intl.NumberFormat("ja-JP", {
minimumIntegerDigits: 6,
useGrouping: false,
});
export const CountDisplay: FC<Props> = ({ value }) => {
return <div className={styles.counter}>{formatter.format(value)}</div>;
};
まとめ
このように、App Router において「Server Component のレンダー結果は 'use client'
つきコンポーネント1つだけ」という形式で実装した場合、Pages Router のときの実装と似た「環境の分離」「境界」ができるのが分かったと思います。
両者の「境界」の似ている部分、異なる部分は以下のとおりです。
Pages の例 | App の例(RSC) | |
---|---|---|
サーバー側 | getServerSideProps |
CounterPageServer |
シリアライズ形式 | JSON 形式 | RSC Payload 形式 |
クライアント側 |
CounterPage CountDisplay
|
CounterPageClient CountDisplay
|
ビルド時のチェック | なし | あり |
React Server Components (RSC) は、この「getServerSideProps
側の世界」と「従来のコンポーネントの世界」から発展・改良を遂げたものです。ファイルの import / export の関係性を利用して、以下のような機能を提供しています。
-
サーバー/ブラウザのソースコードの分離
――ソースコード自体に「クライアント側のみ」/「サーバー側のみ」と制限を掛ける-
import 'client-only'
/import 'server-only'
- また、内部で使われている Node.js Conditional Exports の直接利用[2][3]
-
-
サーバー/ブラウザのシームレスな結合
――「境界」を示しつつ、その境界を超えた連携を可能にする-
'use client'
/'use server'
-
サーバー環境 | 従来の環境 | |
---|---|---|
node --conditions |
react-server |
- |
Pages の例 | (getServerSideProps )異なるが、役割は近い |
CounterPage CountDisplay
|
App の例 | CounterPageServer |
CounterPageClient CountDisplay
|
ブラウザへ送信 ブラウザで実行 |
されない | される |
コンポーネントの ライフサイクル |
無し | あり |
コンポーネントのレンダー | 一度っきり | 再レンダーあり |
'use client'
に込められた特別な意図が、これで分かっていただけたと思います。これが分かれば、RSC を使った開発も怖くありません。え?Next.js のキャッシュが難しい?ちょっと僕もまだ見解がまとまらないので待って…
あとは、Single Source of Truth としての React 公式ドキュメントに目を通して、網羅的な知識を身に着けましょう!
余談: RSC Payload は何がすごいの?
ここまでは、「RSC / 'use client'
が、getServerSideProps
に毛が生えた程度のモノ」という語り口でハードルを下げようとしました。なので「🤔 getServerSideProps
/ JSON から RSC / RSC Payload に変わって、何が嬉しいの?」という疑問を抱かせてしまったと思います。
「境界」の位置が柔軟に変更できる
先ほどの例では、Pages Router との比較のために「Server Component のレンダー結果は 'use client' つきコンポーネント1つだけ」という形を取りましたが、実際には、RSC のおかげで、そのような決まった形式にとらわれず、「境界」の位置を自由に変更できます。
たとえば、以下ように変更すると、Client Component が全く残らず、Server Component だけで描画を完成させることになります。
import { unstable_noStore } from "next/cache";
- import { CounterPageClient } from "./page.client";
+ import { CountDisplay } from "@/_common/counter/count-display";
// 「いまのアクセスが何番目か」を取得できるオブジェクト(注意: めっちゃ手抜きです!!)
const visitCounter = {
count: 0,
current() {
return ++this.count;
},
};
export default function CounterPageServer() {
unstable_noStore(); // これが無いと、静的ページになってしまう。(next 15.1.0 のデフォルト設定)
const visitCount = visitCounter.current();
- return <CounterPageClient visitCount={visitCount} />;
+ return (
+ <div>
+ <h1>訪問者カウント(App Router)</h1>
+ <CountDisplay value={visitCount} />
+ </div>
+ );
}
また、今のところは CountDisplay は Server Component ですが、「やっぱり、ユーザーのクリックに反応する仕組みがほしい!」または「ブラウザの API を内部で呼び出したい」となった場合には、これに 'use client'
を付与することになります。
そうすると、 CounterPageServer のレンダー結果は以下のようになります。
// RSC Payload に含まれるレンダー結果の情報を擬似的に表現したものです。
// 実際のコードではありません。
import { CountDisplay } from "[project]/src/_common/counter/count-display.tsx";
<div>
<h1>訪問者カウント(App Router)</h1>
<CountDisplay value={1} />
</div>
こうなると、ページのうち一部だけ、つまり CountDisplay
だけが Client Component としてレンダリングされます。(SSR 時のハイドレーションも、この小さな領域が対象になるようです)
この CountDisplay
のような自己完結した動的なコンポーネントが静的なHTML文書の中にポツンと存在するさまを「静かな海の上に浮かぶ島」と見立てて、このような構成は「アイランド」アーキテクチャと呼ばれています。
何となく察しが付くと思いますが、同じ要領で、'use client'
を付けられた境界コンポーネント(アイランド)を、ページ内に複数配置することも可能 です。
▼ アイランドについての分かりやすい解説はこちら
送れるデータの種類が豊富
'use client'
の「境界」を超えて渡せるデータの型については、React 公式ドキュメントに詳しく記載されています。
- プリミティブ
- シリアライズ可能な値を含んだ Iterable
- Date
- プレーンなオブジェクト: オブジェクト初期化子で作成され、シリアライズ可能なプロパティを持つもの
- サーバアクション (server action) としての関数
- クライアントまたはサーバコンポーネントの要素(JSX)
- プロミス
出典:
getServerSideProps
では、JSON に変換できるデータ型しか返せませんでした。なので、ちょっとしたミスで混入した undefined
のせいで画面が表示できなくなり、取り除くのに奔走させられたものです。
Error: Error serializing `.hoge` returned from `getServerSideProps` in "/counter-pages".
Reason: `undefined` cannot be serialized as JSON. Please use `null` or omit this value
一方、'use client'
では、undefined がシリアライズ可能になっただけでなく、bigint, Date などもシリアライズ可能になったので、「いちど文字列に変換されたものを、フロントエンドでパースするので、エラーハンドリングが面倒」なケースに少し対応しやすくなると予想できます。
ついでに、TypedArray や ArrayBuffer が渡せるようになったことで、バイナリデータを送信可能になったようです。JSON で渡すためには一度 base64 にエンコードして送信する必要がありデータ量が増大していたので、その部分で困っていた人もラクになると思います。
Promise も渡せるようになりました。これによって「サーバからクライアントにデータをストリーミングする」ことが可能になったようです。詳しくは公式ドキュメントに記載があるので、そちらに任せます。
余談は以上です。
Discussion