React Server Components と Client Components の使い分け実践ガイド
Next.js App Router で迷いがちな Server / 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 |
実装のベストプラクティス
-
デフォルトは Server Component
'use client'は必要最小限に留める -
データ取得とレンダリングを分離
Server Component でデータ取得、Client Component で UI 制御 -
Server Actions を活用
フォーム送信などは Server Actions で簡潔に実装 -
バンドルサイズを定期的に確認
ビルド時の出力を確認し、肥大化を防ぐ -
段階的に Client Component を追加
まず Server Component で実装し、必要に応じて Client Component に切り替える
お問い合わせ
Yureate では、Next.js App Router を活用した高速・保守性の高い Web アプリケーション開発を支援しています。技術選定から実装まで、お気軽にご相談ください。
