← ブログ一覧

React Server Components と Client Components の使い分け実践ガイド

Next.js App Router で迷いがちな Server / Client Components の判断基準を整理。データ取得・状態管理・パフォーマンス最適化の実装パターンを具体例とともに解説します。

#React#Next.js#TypeScript#パフォーマンス
React Server Components と Client Components の使い分け実践ガイド

React Server Components と Client Components の使い分け実践ガイド

Next.js 13 以降の App Router では、React Server Components (RSC) がデフォルトになりました。しかし「どこで 'use client' を入れるべきか」「状態管理はどうするのか」といった実装判断に迷うケースが多く見られます。

本記事では、受託開発・自社開発の現場で即使える判断基準と実装パターンを、具体的なコード例とともに解説します。


1. Server Components と Client Components の基本理解

Server Components(デフォルト)

  • サーバー側で実行され、HTML として送信される
  • JavaScript バンドルに含まれない(バンドルサイズ削減)
  • データベース・API への直接アクセスが可能
  • useState、useEffect などのフックは使用不可
  • ブラウザ API(window、document など)へのアクセス不可

Client Components(明示的に指定)

  • ファイル先頭に 'use client' ディレクティブを記述
  • ブラウザで実行され、インタラクティブ機能を提供
  • React のフック、イベントハンドラーが使用可能
  • JavaScript バンドルに含まれる(バンドルサイズ増加)

基本原則

「できる限り Server Components を使い、必要な部分だけ Client Components にする」

これにより、バンドルサイズを削減し、初期表示速度を向上できます。


2. 使い分けの判断フローチャート

以下の質問に順番に答えることで、適切なコンポーネントタイプを判断できます。

| 質問 | Yes の場合 | No の場合 | |------|-----------|----------| | ユーザー操作(クリック、入力など)に反応する必要がある? | Client | 次へ | | useState、useEffect、カスタムフックを使う? | Client | 次へ | | ブラウザ API(localStorage、window など)を使う? | Client | 次へ | | サードパーティライブラリがブラウザ専用? | Client | 次へ | | データベース・サーバー側 API に直接アクセスする? | Server | 次へ | | 環境変数(process.env)をサーバー側で安全に使いたい? | Server | 次へ | | 認証トークンなどの機密情報を扱う? | Server | 次へ | | 上記すべてに該当しない静的コンテンツ? | Server | Server |


3. 典型的な実装パターン 5 選

パターン 1: データ取得は Server、表示は Client

ユースケース: API からデータを取得し、クライアント側でソート・フィルタリングを提供する

// app/users/page.tsx (Server Component)
import { UserList } from './UserList';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

async function getUsers(): Promise<User[]> {
  // サーバー側で直接 DB アクセス
  const res = await fetch('https://api.example.com/users', {
    cache: 'no-store', // 常に最新データを取得
  });
  return res.json();
}

export default async function UsersPage() {
  const users = await getUsers();
  
  return (
    <div>
      <h1>ユーザー一覧</h1>
      <UserList initialUsers={users} />
    </div>
  );
}
// app/users/UserList.tsx (Client Component)
'use client';

import { useState } from 'react';

interface User {
  id: string;
  name: string;
  email: string;
  role: string;
}

export function UserList({ initialUsers }: { initialUsers: User[] }) {
  const [users, setUsers] = useState(initialUsers);
  const [filter, setFilter] = useState('');

  const filteredUsers = users.filter(user =>
    user.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <div>
      <input
        type="text"
        placeholder="ユーザー名で検索"
        value={filter}
        onChange={(e) => setFilter(e.target.value)}
        className="border p-2 mb-4"
      />
      <ul>
        {filteredUsers.map(user => (
          <li key={user.id}>{user.name} - {user.email}</li>
        ))}
      </ul>
    </div>
  );
}

ポイント:

  • 初期データは Server Component で取得(SEO 対応、高速)
  • インタラクティブ機能のみ Client Component に分離
  • initialUsers として props でデータを渡す

パターン 2: 認証情報を含むヘッダー取得

ユースケース: Cookie の認証トークンを使ってユーザー情報を取得

// app/dashboard/page.tsx (Server Component)
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { DashboardContent } from './DashboardContent';

interface UserProfile {
  id: string;
  name: string;
  plan: 'free' | 'pro' | 'enterprise';
}

async function getUserProfile(): Promise<UserProfile | null> {
  const cookieStore = cookies();
  const token = cookieStore.get('auth_token')?.value;

  if (!token) return null;

  const res = await fetch('https://api.example.com/me', {
    headers: {
      Authorization: `Bearer ${token}`,
    },
    cache: 'no-store',
  });

  if (!res.ok) return null;
  return res.json();
}

export default async function DashboardPage() {
  const user = await getUserProfile();

  if (!user) {
    redirect('/login');
  }

  return <DashboardContent user={user} />;
}

ポイント:

  • cookies() は Server Component でのみ使用可能
  • 認証トークンがクライアントに露出しない
  • リダイレクトもサーバー側で完結

パターン 3: フォーム送信と Server Actions

ユースケース: フォーム送信時にサーバー側で検証・DB 更新を行う

// app/contact/page.tsx (Server Component)
import { revalidatePath } from 'next/cache';
import { ContactForm } from './ContactForm';

async function submitContact(formData: FormData) {
  'use server'; // Server Action

  const name = formData.get('name') as string;
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  // バリデーション
  if (!name || !email || !message) {
    return { error: 'すべての項目を入力してください' };
  }

  // DB への保存(例)
  await fetch('https://api.example.com/contacts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, email, message }),
  });

  revalidatePath('/contact');
  return { success: true };
}

export default function ContactPage() {
  return (
    <div>
      <h1>お問い合わせ</h1>
      <ContactForm submitAction={submitContact} />
    </div>
  );
}
// app/contact/ContactForm.tsx (Client Component)
'use client';

import { useState } from 'react';

interface Props {
  submitAction: (formData: FormData) => Promise<{ error?: string; success?: boolean }>;
}

export function ContactForm({ submitAction }: Props) {
  const [status, setStatus] = useState<string>('');

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const result = await submitAction(formData);

    if (result.error) {
      setStatus(result.error);
    } else if (result.success) {
      setStatus('送信しました');
      e.currentTarget.reset();
    }
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <input name="name" placeholder="お名前" required className="border p-2 w-full" />
      <input name="email" type="email" placeholder="メールアドレス" required className="border p-2 w-full" />
      <textarea name="message" placeholder="メッセージ" required className="border p-2 w-full" />
      <button type="submit" className="bg-blue-500 text-white px-4 py-2">送信</button>
      {status && <p>{status}</p>}
    </form>
  );
}

ポイント:

  • Server Actions により、API エンドポイントを別途作成する必要がない
  • バリデーションロジックはサーバー側に集約
  • クライアント側は UI 状態管理のみに専念

パターン 4: 条件付きで Client Component をレンダリング

ユースケース: ログイン状態に応じて異なるコンポーネントを表示

// app/layout.tsx (Server Component)
import { cookies } from 'next/headers';
import { LoginButton } from './LoginButton';
import { UserMenu } from './UserMenu';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = cookies();
  const isLoggedIn = !!cookieStore.get('auth_token')?.value;

  return (
    <html lang="ja">
      <body>
        <header>
          <nav>
            <h1>My App</h1>
            {isLoggedIn ? <UserMenu /> : <LoginButton />}
          </nav>
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}

ポイント:

  • 認証状態の判定はサーバー側で行う
  • 必要な Client Component のみを条件付きレンダリング

パターン 5: サードパーティライブラリの扱い

ユースケース: Chart.js などのブラウザ専用ライブラリを使う

// app/analytics/page.tsx (Server Component)
import { Chart } from './Chart';

async function getAnalyticsData() {
  const res = await fetch('https://api.example.com/analytics');
  return res.json();
}

export default async function AnalyticsPage() {
  const data = await getAnalyticsData();
  
  return (
    <div>
      <h1>アクセス解析</h1>
      <Chart data={data} />
    </div>
  );
}
// app/analytics/Chart.tsx (Client Component)
'use client';

import { useEffect, useRef } from 'react';
import { Chart as ChartJS, registerables } from 'chart.js';

ChartJS.register(...registerables);

export function Chart({ data }: { data: any }) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    if (!canvasRef.current) return;

    const chart = new ChartJS(canvasRef.current, {
      type: 'line',
      data,
      options: { responsive: true },
    });

    return () => chart.destroy();
  }, [data]);

  return <canvas ref={canvasRef} />;
}

ポイント:

  • ブラウザ専用ライブラリは必ず Client Component で使用
  • データ取得は Server Component で行い、props で渡す

4. よくある実装ミスと対処法

ミス 1: Server Component で useState を使おうとする

エラー例:

// ❌ これはエラーになる
export default function Page() {
  const [count, setCount] = useState(0); // Error!
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

対処法: ファイル先頭に 'use client' を追加

// ✅ 正しい実装
'use client';

import { useState } from 'react';

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

ミス 2: Client Component で直接 DB アクセスを試みる

エラー例:

// ❌ これはセキュリティリスク
'use client';

export default function Page() {
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    // DB 接続情報がクライアントに露出してしまう
    fetch('postgresql://user:pass@host/db')
      .then(res => res.json())
      .then(setUsers);
  }, []);
}

対処法: API Route または Server Component を経由

// ✅ app/api/users/route.ts (API Route)
import { NextResponse } from 'next/server';

export async function GET() {
  // サーバー側で安全に DB アクセス
  const users = await db.query('SELECT * FROM users');
  return NextResponse.json(users);
}

// ✅ app/users/page.tsx (Client Component)
'use client';

export default function Page() {
  const [users, setUsers] = useState([]);
  
  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(setUsers);
  }, []);
}

ミス 3: 不要な Client Component の作成

非効率な例:

// ❌ 全体を Client Component にしてしまう
'use client';

export default function Page() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div>
      <Header /> {/* 静的コンテンツなのに Client になってしまう */}
      <button onClick={() => setIsOpen(!isOpen)}>開く</button>
      {isOpen && <Modal />}
      <Footer /> {/* これも不要に Client になる */}
    </div>
  );
}

最適化例:

// ✅ 必要な部分だけ Client Component に
// app/page.tsx (Server Component)
import { Header } from './Header';
import { Footer } from './Footer';
import { ToggleModal } from './ToggleModal';

export default function Page() {
  return (
    <div>
      <Header /> {/* Server Component */}
      <ToggleModal /> {/* Client Component */}
      <Footer /> {/* Server Component */}
    </div>
  );
}

// app/ToggleModal.tsx (Client Component)
'use client';

import { useState } from 'react';
import { Modal } from './Modal';

export function ToggleModal() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <>
      <button onClick={() => setIsOpen(!isOpen)}>開く</button>
      {isOpen && <Modal />}
    </>
  );
}

5. パフォーマンス最適化のチェックリスト

実装時に以下の項目を確認することで、不要なバンドルサイズ増加を防げます。

必須項目

  • [ ] Client Component は最小限に
    インタラクティブ機能が必要な部分だけ 'use client' を使用

  • [ ] データ取得は Server Component で
    初期データは Server Component で取得し、props で渡す

  • [ ] 認証情報はサーバー側のみ
    cookies()、headers() は Server Component で使用

  • [ ] 大きなライブラリは動的インポート
    Client Component でも、必要になるまでインポートを遅延

推奨項目

  • [ ] Server Actions の活用
    フォーム送信などは API Route より Server Actions が簡潔

  • [ ] 適切なキャッシュ戦略
    fetch の cache オプション、revalidate を設定

  • [ ] Suspense による段階的レンダリング
    非同期コンポーネントを Suspense で囲み、ローディング UX を改善


6. デバッグと検証の方法

バンドルサイズの確認

Next.js のビルド時に、各ページのバンドルサイズが表示されます。

npm run build

出力例:

Route (app)                              Size     First Load JS
┌ ○ /                                    1.2 kB          85 kB
├ ○ /dashboard                           3.5 kB          95 kB
└ ○ /analytics                           45 kB          130 kB  # Chart.js を含むため大きい

対処:

  • 特定ページが大きすぎる場合、Client Component の範囲を見直す
  • 動的インポートで遅延ロード

React DevTools での確認

Chrome 拡張「React Developer Tools」の Profiler タブで、レンダリング回数を確認できます。

チェックポイント:

  • 不要な再レンダリングが発生していないか
  • Client Component が多すぎないか

Next.js の開発モードでの警告確認

開発サーバー実行時に、不適切な実装があると警告が表示されます。

npm run dev

例:

Warning: useState can only be used in Client Components.
Add "use client" directive to the top of the file.

7. 実務での設計判断フロー

新しいページ・機能を実装する際の判断手順を整理します。

ステップ 1: 要件の整理

  • [ ] ユーザー操作が必要か?(クリック、入力、ドラッグなど)
  • [ ] リアルタイム更新が必要か?(WebSocket、Polling など)
  • [ ] 外部ライブラリの依存関係は?

ステップ 2: データフローの設計

  • [ ] 初期データはどこから取得する?(DB、API、静的ファイル)
  • [ ] データの更新頻度は?(静的、リアルタイム、ユーザー操作時)
  • [ ] 認証・認可が必要か?

ステップ 3: コンポーネント分割

  • [ ] 静的コンテンツ → Server Component
  • [ ] インタラクティブ機能 → Client Component(最小範囲)
  • [ ] 共通レイアウト → Server Component

ステップ 4: 実装と検証

  • [ ] ビルドサイズを確認
  • [ ] Lighthouse でパフォーマンススコアを測定
  • [ ] 実際のネットワーク環境でテスト(Fast 3G など)

8. まとめ

判断基準の要点

| 実装内容 | 推奨タイプ | |---------|----------| | データ取得(DB、API) | Server Component | | 静的コンテンツ表示 | Server Component | | 認証・Cookie 操作 | Server Component | | ユーザー操作(クリック、入力) | Client Component | | useState、useEffect | Client Component | | ブラウザ API(localStorage など) | Client Component | | サードパーティライブラリ(ブラウザ専用) | Client Component |

実装のベストプラクティス

  1. デフォルトは Server Component
    'use client' は必要最小限に留める

  2. データ取得とレンダリングを分離
    Server Component でデータ取得、Client Component で UI 制御

  3. Server Actions を活用
    フォーム送信などは Server Actions で簡潔に実装

  4. バンドルサイズを定期的に確認
    ビルド時の出力を確認し、肥大化を防ぐ

  5. 段階的に Client Component を追加
    まず Server Component で実装し、必要に応じて Client Component に切り替える


お問い合わせ

Yureate では、Next.js App Router を活用した高速・保守性の高い Web アプリケーション開発を支援しています。技術選定から実装まで、お気軽にご相談ください。

→ お問い合わせはこちら

この内容について相談する他の記事を見る