C
Next.js/基礎/Lesson 02

クライアントとサーバーの境界 — 'use client' がすべてを変える

35分·theory
このチャプター
2/3
TypeScript

クライアントとサーバーの境界 — 'use client' がすべてを変える

💡 なぜ学ぶべきか? — サーバー/クライアントの境界はバンドルサイズとセキュリティの境界

🎯 App Router のすべてのコンポーネントはデフォルトで Server Component です。'use client' をどこに置くかがクライアントバンドルのサイズを決定します。
💼 Client Component の中で DB・シークレット・`fs` モジュールをインポートするとビルドエラーになります — 境界を誤ると、サービス自体がビルドできなくなります。
逆に、すべてを 'use client' にしてしまうと、Server Component の利点(0 KB バンドル、直接 DB アクセス)をすべて失います — それはただの古い SPA モデルです。
🔗 最も重要なパターン: **Server が Client を children として包む composition** — これができないと、Client Component の中でサーバー取得データを使用できません。
🏢 실무에서는
実務で最もよくあるミス: ページコンポーネントの先頭に無意識に 'use client' を書き、その中で `await db.user.findMany()` を呼び出す → ブラウザから DB を呼べないためビルドエラーになります。解決策は、'use client' を本当にインタラクションが必要な小さな leaf コンポーネント(ボタン・フォーム・useState を使う部分)にだけ置き、それ以外は Server Component のままにすることです。

境界の3つの核心ルール

1. 'use client' の本当の意味

tsx
'use client';
  • このファイルとこのファイルがインポートするすべてのコンポーネントが Client Component になります(感染性があります)。
  • つまり、'use client' は「このコンポーネントだけ」ではなく、「ここを起点とするツリー」の境界を示すものです。
  • 1つのページの中に複数の 'use client' 境界があっても自然なことです。

2. 'use client' が必要な5つのシグナル

シグナル
状態 hookuseState, useReducer
副作用 hookuseEffect, useLayoutEffect
ブラウザ APIwindow, localStorage, navigator
イベントハンドラーonClick, onChange, onSubmit
Context Provider<MyContext.Provider>(多くの場合)

このうち1つでも必要なら → 'use client'。不要なら Server Component のままに。

3. Server → Client は OK、Client → Server は ❌

tsx
// ✅ Server が Client をインポートして埋め込む — 可能
// app/page.tsx (Server)
import { Button } from './Button';  // Button は 'use client'
export default async function Page() {
  const data = await db.posts.findMany();
  return <div>{data.length}件<Button /></div>;
}
tsx
// ❌ Client が Server を直接インポートして埋め込むとビルドエラー
// Header.tsx (Client)
'use client';
import { ServerWidget } from './ServerWidget'; // 'use client' なし
export function Header() {
  return <ServerWidget />; // ❌ ビルドエラー
}

4. 解決策 — children prop による composition

tsx
// ClientShell.tsx (Client)
'use client';
import { useState } from 'react';
export function ClientShell({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>トグル</button>
      {open && children}  {/* Server コンテンツがここに入る */}
    </div>
  );
}
tsx
// app/page.tsx (Server)
import { ClientShell } from './ClientShell';
import { ServerWidget } from './ServerWidget'; // Server
export default function Page() {
  return (
    <ClientShell>
      <ServerWidget />  {/* Server が Client の children として渡される — OK */}
    </ClientShell>
  );
}

ポイント: Client が Server を「インポート」して直接埋め込むことはできませんが、Server が生成した JSX を Client の children prop として「渡す」ことは問題ありません。これが App Router で最も重要なパターンです。

5. Server Component 内でよく混乱すること

tsx
// ❌ Server で onClick — ビルドエラー
export default function Page() {
  return <button onClick={() => alert('hi')}>X</button>;
}

// ✅ クリック動作は Client の leaf コンポーネントに切り出す
// app/page.tsx
import { AlertButton } from './AlertButton';
export default function Page() {
  return <AlertButton />;
}
// AlertButton.tsx
'use client';
export function AlertButton() {
  return <button onClick={() => alert('hi')}>X</button>;
}
💻 🅰️ よくある間違い — ページ全体に 'use client' を付ける
// ❌ Pages Router — ページ全体を 'use client' にしたアンチパターン

// 📁 app/posts/page.tsx
'use client';
import { useState, useEffect } from 'react';

interface Post { id: number; title: string; body: string; }

export default function PostsPage() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [liked, setLiked] = useState<Set<number>>(new Set());

  // クライアントでデータフェッチ — 点滅・SEO損失・バンドルが重い
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(setPosts);
  }, []);

  const toggle = (id: number) => {
    const n = new Set(liked);
    n.has(id) ? n.delete(id) : n.add(id);
    setLiked(n);
  };

  return (
    <ul>
      {posts.map(p => (
        <li key={p.id}>
          <h2>{p.title}</h2>
          <p>{p.body}</p>
          <button onClick={() => toggle(p.id)}>
            {liked.has(p.id) ? '❤️' : '🤍'}
          </button>
        </li>
      ))}
    </ul>
  );
}

// 欠点:
// - posts データ・レンダーコードすべてクライアントバンドルに含まれる
// - 初期ページ = 空白画面 → useEffect 後に点滅 → 表示
// - SEO損失 (検索エンジンが空白のHTMLしか見ない)
// - DB直接アクセス不可 → 必ず /api/posts を経由する必要がある
💻 🅱️ 境界をうまく引いたパターン — Server がデータを担い、Client はいいねボタンだけ
// ✅ 境界をリーフまで下げる — Server がデータ、Client はトグルのみ

// 📁 app/posts/page.tsx (Server)
import { LikeButton } from './LikeButton';

interface Post { id: number; title: string; body: string; }

// コンポーネント自体が async — DB直接
export default async function PostsPage() {
  const posts: Post[] = await db.post.findMany();

  return (
    <ul>
      {posts.map(p => (
        <li key={p.id}>
          <h2>{p.title}</h2>
          <p>{p.body}</p>
          <LikeButton postId={p.id} />  {/* Client leaf */}
        </li>
      ))}
    </ul>
  );
}

// 📁 app/posts/LikeButton.tsx (Client)
'use client';
import { useState } from 'react';

export function LikeButton({ postId }: { postId: number }) {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? '❤️' : '🤍'}
    </button>
  );
}

// 📁 app/dashboard/page.tsx — children で Server コンテンツを挟む
import { ClientShell } from './ClientShell';
import { ServerStats } from './ServerStats';

export default function Dashboard() {
  return (
    <ClientShell>
      <ServerStats />  {/* Server コンポーネントを children で — OK */}
    </ClientShell>
  );
}

// 📁 app/dashboard/ClientShell.tsx
'use client';
import { useState } from 'react';

export function ClientShell({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(true);
  return (
    <div>
      <button onClick={() => setOpen(!open)}>トグル</button>
      {open && children}  {/* Server JSX がそのままレンダーされる */}
    </div>
  );
}

// 利点:
// - PostsPage のコードはクライアントバンドルに ❌ — HTML のみ送信
// - LikeButton のみ小さなチャンクで分離され hydration
// - SEO完璧 (サーバーですべての posts がレンダーされる)
// - DB直接アクセス可能

💡 💡 境界をうまく引く5つのルール

1. 'use client' はできるだけ leaf に近い場所に
ページやレイアウトのような大きなコンポーネントに 'use client' を付けるのはほぼ誤った判断です。本当にインタラクションが必要な小さなコンポーネント(ボタン・インプット・ドロップダウン)にだけ使いましょう。

2. 'use client' の感染性を忘れない
'use client' が付いたファイルがインポートするものはすべて Client Component になります。Server Component であっても、そのインポートツリーの中に入ればクライアント扱いになります。

3. Client 内で Server コンテンツが必要なら children で受け取る

tsx
<ClientLayout><ServerContent /></ClientLayout>
// ClientLayout は props として children だけを受け取る

4. Server に onClick は付けられない — Client の leaf に切り出す
ビルドエラーとして検出されますが、パターンを最初から知っておくほうが効率的です。

5. 'use client' を付けても、hydration 前はサーバーでもレンダリングされる
初期 HTML はサーバーが生成し、ブラウザで hydration によって動作するようになります。つまり SSR は常に行われます。'use client' は「ブラウザ専用」という意味ではなく、「hydration 後にブラウザでも動作する」という印です。

⚡ 自分で試してみよう — 境界違反チェックシミュレーション

各コンポーネントがどの環境で動作できるかをシミュレートします。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

確認クイズ

次のうち、Server Component 内でそのまま使えるものはどれですか?(Client Component への切り出しが不要なもの)
💡 データベース・環境変数・ファイルシステムへのアクセスは **Server 専用** の能力です。`await db.user.findMany()` は Server Component で直接使用できます(むしろ Server Component の核心的な利点です)。useState・onClick・useEffect はすべてブラウザ上でのみ動作するため、Client Component として分離する必要があります。これが Server Component を使うかどうかの最も重要な判断基準です。
Client vs Server の境界 - Next.js