← ブログ一覧

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 への直接アクセスが可能
  • useStateuseEffect などのフックは使用不可
  • ブラウザ API(windowdocument など)へのアクセス不可

Client Components(明示的に指定)

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

基本原則

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

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


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

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

| 質問 | Yes の場合 | No の場合 | |------|-----------|----------| | ユーザー操作(クリック、入力など)に反応する必要がある? | Client | 次へ | | useStateuseEffect、カスタムフックを使う? | Client | 次へ | | ブラウザ API(localStoragewindow など)を使う? | 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 が簡潔

  • [ ] 適切なキャッシュ戦略
    fetchcache オプション、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 | | useStateuseEffect | 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 アプリケーション開発を支援しています。技術選定から実装まで、お気軽にご相談ください。

お問い合わせはこちら

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