目次
24 章

Server Components vs Client Components

2 種類のコンポーネントの違い、`use client` ディレクティブの正確な意味、両方を混ぜて使うパターン(サーバーがクライアントを import / クライアントがサーバーを children として受け取る)、そして props シリアライズ制約まで。

第23章で Next.js プロジェクトを作り、App Router のルーティングを身につけました。その過程で作ったページはすべて Server Component でした。本章では 2 種類のコンポーネント(Server / Client)がどう違うのか、そしてどう混ぜて使うのかを整理します。

本章の props シリアライズ制約は、第17章(props と children の型付け)の props モデルを RSC 環境で改めて確認する場面になります。そして第27章(Server Actions とフォーム)で出会う「サーバー関数をクライアントに直接渡す」優雅な例外も、本章の制約の上で初めて意味が見えてきます。

両者の違いを一目で #

Server ComponentClient Component
実行場所サーバー(一度)サーバー(SSR) + クライアント(hydration)
コードがクライアントに行くか?
useState / useEffect
イベントハンドラ(onClick など)
async / await 直接使用(制限あり)
DB / 環境変数 直接アクセス
ブラウザ API(windowlocalStorage)
fspath のような Node.js モジュール
デフォルト(App Router にて)(明示的な切り替えが必要)

この表を覚える必要はありません。核心は 「どこで実行されるのか?」 です。サーバーでのみ実行されるならブラウザでのみ意味があるもの(state、イベント、ブラウザ API)が使えないのが自然で、クライアントに行くコードならサーバー資源(DB、ファイルシステム)にアクセスできないのが当然です。

‘use client’ ディレクティブ #

Client Component にしたいファイルは、先頭に 'use client' 一行 を追加します。

src/app/Counter.tsx
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      カウント: {count}
    </button>
  );
}

これで全部です。'use client' のあるファイルと、それが import するすべてのファイルがクライアントバンドルに含まれます。逆に、ディレクティブがなければそのファイルは Server Component でクライアントには行きません。

‘use client’ の正確な意味 #

正確に言えば、'use client' は「サーバー / クライアント境界」を引くマーカーです。ディレクティブのあるファイルが Client Component で、その子は別途ディレクティブなしでも自動で Client Component になります。

境界モデル
[Server] HomePage
  ↓ import
[Server] ServerOnlyChart
  ↓ import
[Client] Counter ('use client')   ← ここから境界
  ↓ import
[Client] CounterIcon              ← 自動で Client

Counter'use client' があれば、Counter が import する CounterIcon は別途ディレクティブなしでも Client になります。一度境界を越えれば、その下はすべて Client ツリーになります。

実験 1 — Server Component で useState を使ってみる #

直接エラーを一度見ると頭に焼き付きます。

src/app/page.tsx(意図的なエラー)
import { useState } from 'react';  // 🚫 Server Component で

export default function HomePage() {
  const [count, setCount] = useState(0);
  return <div>{count}</div>;
}

保存すると、dev サーバー / ブラウザが次のようなエラーを表示します。

エラー
You're importing a component that needs `useState`. This React hook only works
in a client component. To fix, mark the file (or its parent) with the
`"use client"` directive.

解決策は 'use client' を先頭に追加するか、useState が必要な部分だけを別の Client Component に切り出すこと(後者の方が普通は良いです。下で説明)。

実験 2 — Server Component で await を使ってみる #

逆に、Server Component では関数自体を async にして await を自由に使えます。

src/app/page.tsx
export default async function HomePage() {
  const data = await fetch('https://api.github.com/repos/facebook/react')
    .then(res => res.json());

  return (
    <div style={{ padding: '24px' }}>
      <h1>{data.full_name}</h1>
      <p> {data.stargazers_count.toLocaleString()}</p>
      <p>{data.description}</p>
    </div>
  );
}

ページ関数に async を付けて、fetch を直接 await しています。データを取ってきた後でようやく HTML が作られ、完成した HTML がクライアントに行きます。データフェッチのコードがクライアントに行かないので、API キーや認証トークンを安全に使えます。

これは Client Component では一般にできないことで、Server Component の最も代表的な強みの 1 つです。第21章の最後の節の RSC プレビューパターンが、まさにこの形でした。詳しくは続く 第25章 データフェッチとキャッシュで扱います。

どう混ぜて使うか #

ほとんどのページは 両者が混ざった形 になります。静的な部分(ヘッダー、本文テキスト、データ表示)は Server Component、インタラクションが必要な部分(フォーム、トグル、ドロップダウン)だけ Client Component に。

パターン 1. サーバーがクライアントを import する #

最もよくあるパターンです。

src/app/page.tsx (Server Component)
import Counter from './Counter';

export default async function HomePage() {
  const data = await fetch(/* ... */).then(r => r.json());

  return (
    <div>
      <h1>{data.title}</h1>
      <p>{data.description}</p>
      <Counter />     {/* Client Component */}
    </div>
  );
}
src/app/Counter.tsx (Client Component)
'use client';

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  return <button onClick={() => setCount(count + 1)}>+1 · {count}</button>;
}

ページの外殻は Server Component でデータを埋めてレンダリングし、クリックが必要な小さな部分(Counter)だけを Client Component として切り出し 差し込みました。

この分離の核心は、Counter コンポーネントのコードと React、useState だけがクライアントに行き、ページ全体や取ってきたデータはクライアントに行かないという点です。バンドルサイズ削減の実体がこれです。

パターン 2. クライアントがサーバーの子を children として受け取る #

よくある落とし穴を 1 つ — Client Component の中で Server Component を直接 import することはできません。いったん Client 境界を越えると、その下はすべて Client とみなされるからです。

🚫 動作しない
'use client';

import ServerOnlyChart from './ServerOnlyChart';  // 自動で Client に変換される

export default function Wrapper() {
  // ...
}

解決策は Server Component を children prop として受け取る ことです。

src/app/Wrapper.tsx (Client)
'use client';

import { useState } from 'react';
import type { ReactNode } from 'react';

export default function Wrapper({ children }: { children: ReactNode }) {
  const [open, setOpen] = useState(true);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>トグル</button>
      {open && <div>{children}</div>}
    </div>
  );
}
src/app/page.tsx (Server)
import Wrapper from './Wrapper';
import ServerOnlyChart from './ServerOnlyChart';  // 親 (page) が Server なので安全

export default function HomePage() {
  return (
    <Wrapper>
      <ServerOnlyChart />
    </Wrapper>
  );
}

Wrapper(Client)は子が何かを知りません。ただ children を受け取って、トグルの表示 / 非表示だけを処理します。実際の子(ServerOnlyChart)は親(HomePage、Server)で import されてすでにサーバーでレンダリングされた結果として入ってきます。境界を越えずに 2 種類を混ぜられるパターンです。

このパターンは Modal、Dialog、トグルのように「外殻はインタラクティブだが中身は静的な」コンポーネントを作るときに非常に便利です。

どのコンポーネントをどこに置くか — ガイドライン #

新しいコンポーネントを作るときに意識する流れ。

  1. デフォルトは Server Component'use client' を付けない
  2. 次のいずれかが必要なら Client に切り替え:
    • useStateuseReduceruseContextuseEffectuseRef、その他のフック
    • イベントハンドラ(onClickonChange、…)
    • ブラウザ API(windowdocumentlocalStoragegeolocation、…)
    • クライアントライブラリ(例: framer-motion の一部)
  3. 切り替えるときは そのインタラクションが必要な最も小さい部分だけ を切り出し、親は Server のままにしておく

最後のポイントが重要です。「このページに 1 箇所でもインタラクションがあるからページ全体を Client に」してしまうと、RSC の利点が消えます。インタラクションのある子だけを Client に して、親(ページ)は Server に保ってください。

props でデータを渡すとき — シリアライズ #

Server Component → Client Component に props を渡すとき、1 つ制約があります。props はシリアライズ可能でなければなりません(サーバーで作った値をシリアライズしてクライアントに送る構造であるため)。

第17章(props と children の型付け)で定義した props が、本節の制約の上でもう一度意味を持ちます。

シリアライズ可能シリアライズ不可
string / number / boolean / null / undefined関数(イベントハンドラなど)
一般オブジェクト / 配列クラスインスタンス(独自のメソッドを持つオブジェクト)
Date / Map / SetSymbol
Promise(第26章 use() と組)React コンポーネントではない任意の React ノードの一部
React コンポーネント(子)

そのため イベントハンドラを Server Component で作って Client に渡すことはできません

🚫 ダメ
// page.tsx (Server Component)
export default function HomePage() {
  function handleClick() {  // この関数はクライアントに行けない
    console.log('サーバーで定義された関数');
  }
  return <Button onClick={handleClick} />;  // エラー
}

代わりにクライアント側でハンドラを定義します。

✅ 正常
// Button.tsx
'use client';
export default function Button() {
  function handleClick() { /* ... */ }
  return <button onClick={handleClick}>クリック</button>;
}

優雅な例外 — Server Actions #

続く第27章(Server Actions とフォーム)で扱う Server Actions は、上記制約の優雅な例外です。サーバー関数をクライアントに直接渡せる特殊なメカニズム です。

第27章で出会うモデルのプレビュー
// サーバー関数(別ファイルまたは 'use server' ディレクティブ)
async function deletePost(id: string) {
  'use server';
  await db.posts.delete(id);
}

// クライアントが props として受け取って呼び出す
<DeleteButton onDelete={deletePost} />

内部的に Server Action は通常の関数シリアライズではなく、RPC(Remote Procedure Call)メカニズム で動作します。クライアントは関数を受け取るのではなく、「この関数をサーバーで呼んで」という参照を受け取るのです。

第27章で本格的に扱います。本章では「シリアライズ制約に優雅な例外が 1 つある」とだけ頭に置いておけば十分です。

動作確認 — 小さな例 #

第23章のサイトにインタラクションを加えてみます。ヘッダーにダークモードトグルを追加し、ポスト詳細ページにいいねボタンを付けます。

src/app/ThemeToggle.tsx:

src/app/ThemeToggle.tsx (Client)
'use client';

import { useState, useEffect } from 'react';

export default function ThemeToggle() {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  useEffect(() => {
    document.documentElement.dataset.theme = theme;
  }, [theme]);

  return (
    <button
      onClick={() => setTheme(prev => prev === 'light' ? 'dark' : 'light')}
      style={{ marginLeft: 'auto', padding: '4px 12px' }}
    >
      {theme === 'light' ? '🌙' : '☀'}
    </button>
  );
}

src/app/LikeButton.tsx:

src/app/LikeButton.tsx (Client)
'use client';

import { useState } from 'react';

type Props = {
  initial?: number;
};

export default function LikeButton({ initial = 0 }: Props) {
  const [count, setCount] = useState(initial);
  const [liked, setLiked] = useState(false);

  function toggle() {
    if (liked) {
      setCount(c => c - 1);
      setLiked(false);
    } else {
      setCount(c => c + 1);
      setLiked(true);
    }
  }

  return (
    <button onClick={toggle} style={{ padding: '8px 16px' }}>
      {liked ? '❤' : '🤍'} {count}
    </button>
  );
}

src/app/layout.tsx のヘッダー部分に ThemeToggle を追加:

src/app/layout.tsx(修正)
import Link from 'next/link';
import ThemeToggle from './ThemeToggle';
import type { ReactNode } from 'react';
import './globals.css';

export const metadata = { title: 'モダン React デモ' };

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <header style={{ display: 'flex', alignItems: 'center', padding: '12px 24px', background: '#222', color: '#fff' }}>
          <Link href="/" style={{ color: '#fff', marginRight: '16px' }}>ホーム</Link>
          <Link href="/about" style={{ color: '#fff', marginRight: '16px' }}>紹介</Link>
          <Link href="/posts" style={{ color: '#fff' }}>ポスト</Link>
          <ThemeToggle />
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

src/app/posts/[slug]/page.tsx に LikeButton を追加:

src/app/posts/[slug]/page.tsx(修正)
import LikeButton from '../../LikeButton';

type Props = {
  params: Promise<{ slug: string }>;
};

export default async function PostPage({ params }: Props) {
  const { slug } = await params;
  return (
    <div style={{ padding: '24px' }}>
      <h1>{slug}</h1>
      <p>このページはスラッグ "{slug}" の本文です。</p>
      <LikeButton initial={0} />
    </div>
  );
}

ここで核心 — layout.tsxpage.tsx は依然として Server Component です。'use client' がありません。ただその中に Client Component(ThemeToggleLikeButton)をインポートして使うだけです。ページ全体のコードはクライアントに行かず、小さなインタラクティブな断片だけがクライアントに行きます。RSC の利点をそのまま享受できるわけです。

ブラウザの開発者ツールの Network タブで JavaScript バンドルを確認してみると、ページを何個作っても、クライアントに送られる JS は(インタラクティブコンポーネント以外は)あまり増えないことが分かります。

練習問題 #

  1. 上の例で ThemeToggle'use client' を外してみてください。dev サーバー / ブラウザにどんなエラーが出るか確認します。その後 LikeButtoninitial prop の位置に () => 0 のような関数を渡してみてください。シリアライズ制約によるエラーがどう表示されるかを直接見ます。
  2. Wrapper パターンを自分で作る。'use client' の付いた Collapsible コンポーネントを作り、その子として Server Component ServerOnlyChart を差し込んでみてください。<Collapsible><ServerOnlyChart /></Collapsible> の形。Collapsible のトグルが動作しつつ、ServerOnlyChart のコードがクライアントに行かないことを Network タブで確認します。
  3. props シリアライズ境界の探索。Server Component で次の値を Client Component に props として渡してみて結果を観察してください。(a) new Date()、(b) { name: 'カーティス', greet: () => 'hi' }、(c) [1, 2, 3]、(d) new URL('https://example.com')。どれが通過してどれがエラーになるかを直接手に馴染ませてみます。

一行まとめ: Server Component(デフォルト)はサーバーでのみ実行され、コードがクライアントに行かない。'use client' を付けると Client Component になり、その子は自動で Client。ページ(Server)の中にインタラクティブな子(Client)を差し込むパターンが標準で、Client が Server の子を受け取らないといけない場合は children prop で回避する。props はシリアライズ可能な値だけ(関数 / クラスインスタンスを除く)。第27章の Server Actions がこの制約の優雅な例外。

次の章 #

次の 第25章 データフェッチとキャッシュでは、Server Component の最も強力な機能 — async / await でデータを直接取ってくる パターンを本格的に扱います。第21章の useEffect + fetch + ローディング state の三段コンボがたった 2 行に縮む姿を見ることになります。そして Next.js 15 のキャッシュモデル — force-cache / no-store / revalidate — も一度に扱います。

X