モダンReact + Next.js #3 Server Components vs Client Components

読了 10分

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

両者の違いを一覧で #

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'の1行を追加します。

src/app/Counter.jsx
'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'は「サーバー/クライアント境界」を引く目印です。ディレクティブがあるファイルはClient Componentで、その子は別途ディレクティブがなくても自動的にClient Componentになります。Server Componentの中でClient Componentをimportして使うこともでき、その逆方向には少し制約があります(以下で扱います)が、この境界はライブラリ作成者や大きなコードベースを書くのでなければ自然に慣れていきます。

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

直接エラーを一度見ると頭に入りやすくなります。

src/app/page.js (意図的なエラー)
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番目の方法が通常より良い — 以下で説明)。

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

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

src/app/page.js
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つです (詳細は#4で)。

どう混ぜて使うべきか #

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

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

最もよく使われるパターンです。

src/app/page.js (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.jsx (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として受け取る #

よくある落とし穴 — 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.jsx (Client)
'use client';

import { useState } from 'react';

export default function Wrapper({ children }) {
  const [open, setOpen] = useState(true);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>トグル</button>
      {open && <div>{children}</div>}
    </div>
  );
}
src/app/page.js (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はシリアライズ可能でなければなりません (サーバーで作った値をシリアライズしてクライアントに送る構造なので)。

シリアライズ可能なもの:

  • プリミティブ値 (string、number、boolean、null、undefined)
  • 通常のオブジェクトと配列
  • Date
  • Map、Set
  • Promise (#5で扱う)
  • React要素

シリアライズ不可:

  • 関数
  • クラスインスタンス (独自のメソッドを持つオブジェクト)

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

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

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

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

または#6で扱うServer Actionsはこの制約のエレガントな例外です — サーバー関数をクライアントに直接渡せる特殊なメカニズムです。一般的な関数とは異なるメカニズムで動作します。

動作確認 — 小さな例 #

前回の記事のサイトにインタラクションを加えてみましょう。ヘッダーにダークモードのトグルを追加し、記事詳細ページに「いいね」ボタンを付けます。

src/app/ThemeToggle.jsx:

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

import { useState, useEffect } from 'react';

export default function ThemeToggle() {
  const [theme, setTheme] = useState('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.jsx:

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

import { useState } from 'react';

export default function LikeButton({ initial = 0 }) {
  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.jsのヘッダー部分にThemeToggleを追加:

src/app/layout.js (修正)
import Link from 'next/link';
import ThemeToggle from './ThemeToggle';
import './globals.css';

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

export default function RootLayout({ children }) {
  return (
    <html lang="ko">
      <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.jsにLikeButtonを追加:

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

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

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

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

まとめ #

今回の記事では2種類のコンポーネントを扱いました。

  • Server Component (デフォルト) — サーバーのみで実行、async/await可、コードがクライアントに送られない
  • Client Component ('use client') — ブラウザでも実行、フックとイベントハンドラ使用可
  • 両者は共存する — ページ(Server)の中にインタラクティブな子(Client)を埋め込む形
  • Server → Clientは自然に、Client → Serverはchildrenで迂回
  • propsはシリアライズ可能な値のみ

次の記事「モダンReact + Next.js #4 データフェッチとキャッシング」では、Server Componentの最も強力な機能 — async/awaitでデータを直接取得するパターンを本格的に扱います。クライアントのuseEffect + fetch + ローディングstateの三段コンボがたった2行に減る様子をご覧いただけるでしょう。

X