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 のための機能を備えています。
Pages Router で getServerSideProps を使った場合の挙動
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)レンダーします。
▼ Next.js 公式ドキュメント
▼ getServerSideProps について、分かりやすく解説された記事があるので、詳しく知りたい方はそちらも参照してみてください。
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 の「境界」
Pages Router での「境界」は getServerSideProps と CounterPage の間
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 側の責務です。
RSC の「境界」は 'use client' で宣言される
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 形式 |
| クライアント側 |
CounterPageCountDisplay
|
CounterPageClientCountDisplay
|
| ビルド時のチェック | なし | あり |
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)異なるが、役割は近い |
CounterPageCountDisplay
|
| App の例 | CounterPageServer |
CounterPageClientCountDisplay
|
| ブラウザへ送信 ブラウザで実行 |
されない | される |
| コンポーネントの ライフサイクル |
無し | あり |
| コンポーネントのレンダー | 一度っきり | 再レンダーあり |
'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