Reactのuse clientで、サーバーとクライアントを分ける方法
ReactNext.js,props,TypeScript,サーバーレンダリング
React Server Components では、コンポーネントは何も書かなければ Server Component になり、ファイルの先頭に 'use client' を書いたところが、クライアントとの境目になります。境目は描画の親子ではなく、import の関係で決まります。Next.js 16 で小さなアプリをビルドし、どのコードがブラウザ用の JS に入るか、境目を間違えたときにどんなエラーになるかを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.22.0、Next.js 16.4.3(App Router、ビルドは Turbopack)、React 19.3.1、TypeScript 7.0.2、ブラウザは Chromium(Chrome for Testing 153)です。確かめたのは本番用のビルド(next build)と next start だけで、開発用サーバー(next dev)での表示は確かめていません。Next.js 以外のフレームワークや、Server Functions('use server')は扱っていません。
境目は import の関係で決まる
React の 'use client' のリファレンスには、ファイルの先頭に 'use client' を書くと、そのモジュールと、そこから import されるモジュールがすべてクライアントのコードになると書かれています。import されたファイルに 'use client' がなくても、クライアント側に入ります。
Next.js のドキュメントでは、境目を越えるものは2つだけだと説明されています。コードは import で越え、Client Component が import したものはブラウザ用の JS に入ります。データは props で越え、シリアライズできる値でなければいけません。
'use client' は、ファイルの中で import などのコードより前に書きます(コメントは前にあってもかまいません)。
検証に使ったアプリの import の関係を、図にしました。

検証に使ったアプリ
猫の一覧を表示するページです。ファイルから一覧を読む処理と一覧の表示はサーバー、「いいね」の数と一覧の開け閉めはクライアントに置きました。検証では、どのファイルのコードがブラウザ用の JS に入ったかを調べるために、要素に目印の属性(data-mark)を付けています。記事のコードでは、その属性を省いています。
ファイルを読む処理は、サーバーだけで使います。先頭の import 'server-only' は、後の節で説明します。
// lib/cats.ts
import 'server-only';
import { readFile } from 'node:fs/promises';
export type Cat = { name: string; color: string };
// サーバーだけで使う処理(ファイルを読む)
export async function getCats(): Promise<Cat[]> {
const text = await readFile('data/cats.json', 'utf8');
return JSON.parse(text) as Cat[];
}
一覧を表示するコンポーネントには 'use client' を書いていないので、Server Component です。async の関数にして、描画の中で await できます。
// app/ui/cat-list.tsx
import { getCats } from '../../lib/cats';
export default async function CatList() {
const cats = await getCats();
return (
<ul>
{cats.map((cat) => (
<li key={cat.name}>
{cat.name}({cat.color})
</li>
))}
</ul>
);
}
useState とクリックの処理を使う2つのファイルには、先頭に 'use client' を書きました。Toggle は、一覧を children として受け取ります。
// app/ui/toggle.tsx
'use client';
import { useState } from 'react';
export default function Toggle({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen(!open)}>{open ? '一覧を閉じる' : '一覧を開く'}</button>
{open && children}
</section>
);
}
LikeButton は、数の表示に lib/format.ts の関数を使います。lib/format.ts には 'use client' を書いていません。
// app/ui/like-button.tsx
'use client';
import { useState } from 'react';
import { formatLikes } from '../../lib/format';
export default function LikeButton({ initialLikes, updatedAt }: { initialLikes: number; updatedAt: Date }) {
const [likes, setLikes] = useState(initialLikes);
return (
<div>
<button onClick={() => setLikes(likes + 1)}>{formatLikes(likes)}</button>
<p id="updated">
updatedAt は Date か:{String(updatedAt instanceof Date)}({updatedAt.toISOString()})
</p>
</div>
);
}
// lib/format.ts
export function formatLikes(n: number): string {
return `${n} いいね`;
}
ページは Server Component で、一覧の件数と日付を LikeButton に props で渡し、CatList を Toggle の children に入れています。
// app/page.tsx
import { getCats } from '../lib/cats';
import CatList from './ui/cat-list';
import LikeButton from './ui/like-button';
import Toggle from './ui/toggle';
export default async function Page() {
const cats = await getCats();
return (
<main>
<h1>猫の一覧({cats.length}匹)</h1>
<LikeButton initialLikes={cats.length} updatedAt={new Date('2026-10-23T00:00:00Z')} />
<Toggle>
<CatList />
</Toggle>
</main>
);
}
ブラウザ用の JS に入ったのは、'use client' のファイルと、そこから import したファイルだけ
next build でビルドし、ブラウザ用の JS(.next/static の中)に、各ファイルにしかない文字列が入っているファイルの数を数えました。
| ファイル | 調べた文字列 | 入っていたファイルの数 |
|---|---|---|
app/page.tsx |
ページの目印の属性 | 0 |
app/ui/cat-list.tsx |
一覧の目印の属性 | 0 |
lib/cats.ts |
data/cats.json |
0 |
app/ui/like-button.tsx |
ボタンの目印の属性 | 1 |
app/ui/toggle.tsx |
一覧を開く |
1 |
lib/format.ts |
いいね |
1 |
'use client' を書いた2つのファイルに加えて、書いていない lib/format.ts もブラウザ用の JS に入りました。LikeButton から import されているからです。反対に、ページ、一覧、ファイルを読む処理のコードは入っていません。
'use client' は、境目になるファイルにだけ書けば足ります。そこから import したファイルは、書かなくてもクライアント側に入ります。サーバーに残したいコードは、'use client' のファイルからも、そこから import したファイルからも(その先も含めて)import しないようにします。
Next.js のドキュメントでも、ブラウザへ送る JS を減らすために、画面の大部分ではなく、操作を受け付けるコンポーネントのファイルにだけ 'use client' を書くよう勧めています。
Server Component を children で渡すと、サーバー側のまま
CatList は Toggle(Client Component)の中に表示されますが、ブラウザ用の JS には入りませんでした。Toggle のファイルは CatList を import しておらず、ページが children として渡しているだけだからです。React のリファレンスにも、境目は描画の親子ではなく、import の関係で決まると書かれています。
サーバーで描画した結果(RSC ペイロード)を見ると、CatList はコードではなく、描画した結果のデータとして入っていました(関係する行だけを抜き出しています)。
12:I[87335,["$2","$11"],"default"]
13:I[30179,["$2","$11"],"default"]
6:["$","main",null,{"data-mark":"MARK_PAGE","children":[["$","h1",null,{"children":["猫の一覧(",3,"匹)"]}],["$","$L12",null,{"initialLikes":3,"updatedAt":"$D2026-10-23T00:00:00.000Z"}],["$","$L13",null,{"children":"$L14"}]]}]
14:["$","ul",null,{"data-mark":"MARK_CAT_LIST","children":[["$","li","たま",{"children":["たま","(","白",")"]}],["$","li","みけ",{"children":["みけ","(","三毛",")"]}],["$","li","くろ",{"children":["くろ","(","黒",")"]}]]}]
12 と 13 の行は Client Component のコードへの参照で、渡した props から見て、$L12 が LikeButton、$L13 が Toggle です。Toggle の children には $L14 が入っていて、14 の行に一覧の ul と li がそのまま書かれています。
next start で動かしたページを Chromium で開き、ボタンを押してみました。
# 読み込み直後
ボタン: 3 いいね
updatedAt は Date か:true(2026-10-23T00:00:00.000Z)
li の数: 0
# 「いいね」を1回押したあと
ボタン: 4 いいね
# 「一覧を開く」を押したあと
li: たま(白) / みけ(三毛) / くろ(黒)
ボタンを押してから送られたリクエストの数: 0
一覧は閉じた状態で始まり、「一覧を開く」を押すと3匹が表示されました。ボタンを押してから一覧が出るまで、サーバーへのリクエストは送られていません。ペイロードには一覧を描画した結果(14 の行)が最初から入っていて、ボタンを押してもリクエストは0だったので、一覧は新たに取りに行かずに表示されたと考えられます。
Client Component から import すると、クライアント側に移る
Toggle で children を受け取るのをやめ、CatList を Toggle のファイルから直接 import してみました。ページ側は CatList の import を消し、<Toggle /> だけを置いています。
'use client';
import { useState } from 'react';
+import CatList from './cat-list';
-export default function Toggle({ children }: { children: React.ReactNode }) {
+export default function Toggle() {
const [open, setOpen] = useState(false);
return (
<section>
<button onClick={() => setOpen(!open)}>{open ? '一覧を閉じる' : '一覧を開く'}</button>
- {open && children}
+ {open && <CatList />}
</section>
);
}
ビルドは止まり、エラーが2つ出ました。そのうち1つを抜き出しています。
./lib/cats.ts:1:1
Error: 'server-only' cannot be imported from a Client Component module
> 1 | import 'server-only';
| ^^^^^^^^^^^^^^^^^^^^^
2 | import { readFile } from 'node:fs/promises';
3 |
4 | export type Cat = { name: string; color: string };
It should only be used from a Server Component.
Import traces:
Server Component:
./lib/cats.ts
./app/page.tsx
Client Component Browser:
./lib/cats.ts [Client Component Browser]
./app/ui/cat-list.tsx [Client Component Browser]
./app/ui/toggle.tsx [Client Component Browser]
./app/ui/toggle.tsx [Server Component]
./app/page.tsx [Server Component]
cat-list.tsx にも cats.ts にも 'use client' はありませんが、toggle.tsx から import されたことで、どちらもクライアント側(Client Component Browser)に入っています。エラーの後半に出る import の経路(Import traces)を見ると、どのファイルから境目を越えたかが分かります。
このエラーを出したのは、lib/cats.ts の先頭の import 'server-only' です。Next.js のドキュメントでは、サーバーだけで使うコードがクライアントに入るのを防ぐために、この server-only パッケージを import する方法が紹介されています。
同じ変更で lib/cats.ts から import 'server-only' を外すと、このバージョンでは、ビルドは Turbopack の内部エラーで止まりました(抜き出しです)。
FATAL: An unexpected Turbopack error occurred. A panic log has been written to (一時ファイル).
...
> Build error occurred
Error [TurbopackInternalError]: Failed to write app endpoint /page
Caused by:
- the chunking context (unknown) does not support external modules (request: node:fs/promises)
node:fs/promises を読み込めないことは分かりますが、どのファイルから境目を越えたかは出ていません。今回は node:fs/promises を読み込んでいたので、ビルドが止まりました。Next.js のドキュメントでは、環境変数(API_KEY)を使って外部の API を呼ぶ関数を例に、クライアントでも import して実行できてしまうと説明されています。その場合、NEXT_PUBLIC_ の付かない環境変数は空の文字列に置き換わり、期待どおりに動きません(この場合は試していません)。クライアントに入ってはいけないファイルには、server-only を入れておくと、境目を越えたときにビルドで分かり、原因も探しやすくなります。
フックを使うファイルに 'use client' を付け忘れると、ビルドが止まる
like-button.tsx の先頭の 'use client' を消してビルドすると、次のエラーで止まりました(抜き出しです)。
./app/ui/like-button.tsx:1:10
Error: You're importing a module that depends on `useState` into a React Server Component module. This API is only available in Client Components. To fix, mark the file (or its parent) with the `"use client"` directive.
Learn more: https://nextjs.org/docs/app/api-reference/directives/use-client
> 1 | import { useState } from 'react';
| ^^^^^^^^
'use client' がなければ、ページから import された like-button.tsx は Server Component として扱われます。Next.js のドキュメントでも、状態やクリックなどのイベントの処理、ブラウザの API を使うときは Client Component にするよう説明されています。こうしたファイルには 'use client' が要ります。
props で渡せるのは、シリアライズできる値だけ
Server Component から Client Component へ渡す props は、シリアライズできる値でなければいけません。React のリファレンスでは、文字列や数値、配列、普通のオブジェクトのほか、Date、Map、Set、Promise、JSX などが挙げられています。一方で、関数(Server Functions を除く)や、クラスのインスタンスは渡せません。
Date は、ペイロードでは "$D2026-10-23T00:00:00.000Z" という文字列になっていました。それでもブラウザでは、updatedAt instanceof Date が true になり、Date として受け取れています。
ページから LikeButton に、関数やクラスのインスタンスを渡してみました。検証では、渡す props に合わせて LikeButton の props の型(onLike?: () => void など)も足しています。どれもビルドの型チェックは通り、ページを事前に描画する段階で止まりました(エラーは、その部分だけを抜き出しています)。
// 関数を渡す(名前が on の後に大文字が続く)
<LikeButton
initialLikes={cats.length}
updatedAt={new Date('2026-10-23T00:00:00Z')}
onLike={() => console.log('liked')}
/>
Error: Event handlers cannot be passed to Client Component props.
{initialLikes: 3, updatedAt: Date, onLike: function onLike}
^^^^^^^^^^^^^^^
If you need interactivity, consider converting part of this to a Client Component.
// 関数を渡す(名前が on の後に大文字が続かない)
<LikeButton
initialLikes={cats.length}
updatedAt={new Date('2026-10-23T00:00:00Z')}
getLabel={() => 'いいね'}
/>
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". Or maybe you meant to call this function rather than return it.
{initialLikes: 3, updatedAt: Date, getLabel: function getLabel}
^^^^^^^^^^^^^^^^^
// クラスのインスタンスを渡す
class Owner {
constructor(public name: string) {}
}
<LikeButton
initialLikes={cats.length}
updatedAt={new Date('2026-10-23T00:00:00Z')}
owner={new Owner('たま')}
/>
Error: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Classes or null prototypes are not supported.
{initialLikes: 3, updatedAt: Date, owner: {name: "たま"}}
^^^^^^^^^^^^
関数を渡したときは、props の名前によってメッセージが変わりました。React のソースでは、onLike や onClick のように on の後に大文字が続く名前のときに、イベントの処理を渡したというメッセージを出しています。どちらの場合も、クリックなどの処理は Client Component の中で書き、サーバーからは数値や文字列のようなデータだけを渡すと、境目を越えられます。
境目を置く場所の決め方
ここまでの結果から、境目は次のように決められます。
まず、ページやレイアウトは Server Component のままにしておきます。useState、クリックの処理、ブラウザの API を使うコンポーネントだけをファイルに分け、そのファイルの先頭に 'use client' を書きます。そのファイルから import したものはすべてブラウザ用の JS に入るので、サーバーに残したいコードは、そこから import しません。
Client Component の中にサーバーで描画したものを入れたいときは、import せずに、children などの props で JSX として渡します。サーバーから Client Component へ渡すデータは、シリアライズできる値にします。サーバーだけで使うファイルに import 'server-only' を入れておけば、境目を越えてしまったときに、どのファイルから越えたかがビルドのエラーに出ます。
Posted by 86
関連記事

ReactのflushSyncで、stateの更新をすぐ画面に反映する方法
Reactでstateを更新した直後の行では、画面(DOM)はまだ古いままです。 ...

React 19で、forwardRefを使わずにrefを受け取る方法
React 19では ref をふつうの props として受け取れるので、新し ...

ReactでResizeObserverを使い、要素の幅で表示を変える
画面の幅ではなく、置かれた場所の幅に合わせて、Reactのコンポーネントの表示を ...

ReactのuseIdで、フォームのラベルと入力欄を結びつける方法
入力欄の部品で id を決め打ちにすると、同じ部品を2つ置いたときに id が重 ...

React Routerのアクションで、フォームの送信を受け取る方法
React Routerのデータモードでは、ルートにactionを書くと、For ...
