🔄 React Server ComponentsとClient Components完全解説 - Next.jsアプリのパフォーマンスを最大化する使い分け術
こんにちは、@YushiYamamotoです!
Next.js 13以降のApp Routerで導入された「React Server Components」と「Client Components」。この2つのコンポーネントタイプは、使い方次第でアプリケーションのパフォーマンスを大きく左右します。しかし、「どちらをどのタイミングで使うべきなのか?」「どのように組み合わせると最適なのか?」という疑問を持つ開発者も多いのではないでしょうか。
この記事では、Server ComponentsとClient Componentsのメカニズムから実践的な使い分けまで、具体的なコード例を交えながら解説します。これを読めば、より高速で最適化されたNext.jsアプリケーションを設計できるようになるはずです!
📋 目次
- Server ComponentsとClient Componentsの基本
- レンダリングの仕組み
- それぞれのコンポーネントの使いどころ
- コンポーネント間の連携パターン
- 実装例:最適化されたブログアプリ
- パフォーマンス最適化のベストプラクティス
- よくある質問と回答
- まとめ
Server ComponentsとClient Componentsの基本
Server Components 🖥️
Server Componentsは、サーバー上でのみレンダリングされるコンポーネントです。Next.js App Routerでは、すべてのコンポーネントがデフォルトでServer Componentsとして扱われます。
主な特徴:
- JavaScriptバンドルに含まれない(クライアントへ送信されない)
- データベースやファイルシステムに直接アクセスできる
- 環境変数(秘密キーなど)に安全にアクセスできる
- キャッシュやストリーミングの恩恵を受けられる
- クライアント側のReactフックや状態管理は使用できない
Client Components 📱
Client Componentsは、ブラウザで実行されるインタラクティブなコンポーネントです。'use client'
ディレクティブを使用して明示的に宣言します。
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>カウント: {count}</p>
<button onClick={() => setCount(count + 1)}>
増加
</button>
</div>
)
}
主な特徴:
- Reactフック(useState, useEffectなど)が使用可能
- ブラウザAPI(localStorage, windowなど)にアクセス可能
- イベントリスナー(onClick, onChangeなど)が利用可能
- JavaScriptバンドルに含まれる
- サーバーではHTMLとして先行レンダリングされ、クライアントでハイドレーションされる
レンダリングの仕組み
Next.jsでのレンダリングプロセスを理解することは、両コンポーネントの使い分けを考える上で重要です。次の図は、ページが最初に読み込まれる際のレンダリングフローを示しています:
レンダリングは次の段階で進みます:
-
サーバーサイド:
- Reactは、Server Componentsを「RSC Payload」と呼ばれる特殊なデータ形式にレンダリング
- Next.jsは、RSC PayloadとClient Component用のJavaScriptを使用してHTMLを生成
-
クライアントサイド:
- HTMLは即座に表示される(高速な初期表示を実現)
- RSC Payloadを使用して、クライアントとサーバーのコンポーネントツリーを調整
- JavaScriptがClient Componentsをハイドレーションし、インタラクティブにする
それぞれのコンポーネントの使いどころ
適切なコンポーネントタイプを選択することで、アプリケーションのパフォーマンスと開発体験を大幅に向上させることができます。
Server Componentsに適したケース 🏆
- データフェッチングが必要な場合:データソースに近い場所でフェッチすることで、ウォーターフォールリクエストを減らせます
- 静的なUI要素:ユーザーインタラクションが不要な部分
- SEOが重要なコンテンツ:サーバーでレンダリングされたHTMLは検索エンジンに認識されます
- 大きなライブラリに依存する場合:サーバーでのみ実行することでバンドルサイズを削減
- 機密データへのアクセスが必要な場合:APIキーなどをクライアントに公開せず使用可能
// app/blog/[id]/page.jsx (Server Component)
export default async function BlogPost({ params }) {
// APIキーなどを安全に使用
const apiKey = process.env.API_KEY;
// データベースから直接フェッチ可能
const post = await fetchBlogPost(params.id, apiKey);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
Client Componentsに適したケース 💫
- ユーザーインタラクションが必要な場合:フォーム、ボタン、ドロップダウンなど
- 状態管理が必要な場合:useState, useReducerなどのフックを使用
- ブラウザAPIを使用する場合:localStorage, geolocation, windowなど
- 副作用を使用する場合:useEffectを使ったDOM操作やタイマーなど
- クライアント専用ライブラリに依存する場合:chart.jsなどのDOM依存ライブラリ
// components/ThemeToggle.jsx (Client Component)
'use client'
import { useState, useEffect } from 'react'
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
// ブラウザのローカルストレージからテーマ設定を読み込み
const savedTheme = localStorage.getItem('theme')
setIsDark(savedTheme === 'dark')
}, [])
const toggleTheme = () => {
const newTheme = isDark ? 'light' : 'dark'
setIsDark(!isDark)
localStorage.setItem('theme', newTheme)
document.documentElement.classList.toggle('dark')
}
return (
<button onClick={toggleTheme}>
{isDark ? '🌞 ライトモード' : '🌙 ダークモード'}
</button>
)
}
コンポーネント間の連携パターン
Server ComponentsとClient Componentsを効果的に組み合わせるためのパターンを見ていきましょう。
1. Server ComponentをClient Componentの子として渡す 🧩
Client Componentの中でServer Componentをインポートして使用することはできませんが、propsとして渡すことは可能です。
// app/page.jsx (Server Component)
import ClientWrapper from '@/components/ClientWrapper'
import ServerContent from '@/components/ServerContent'
export default function Page() {
// ServerContentはServer Componentとして動作
return (
<ClientWrapper>
<ServerContent />
</ClientWrapper>
)
}
// components/ClientWrapper.jsx (Client Component)
'use client'
import { useState } from 'react'
export default function ClientWrapper({ children }) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="border rounded p-4">
<button onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? '閉じる' : '開く'}
</button>
{isExpanded && (
<div className="mt-4">
{/* ServerContentがここに挿入される */}
{children}
</div>
)}
</div>
)
}
2. propsを通じてServer Componentからデータを渡す 📊
Server Componentでフェッチしたデータを、Client Componentに渡すパターンは非常に効果的です。
// app/dashboard/page.jsx (Server Component)
import UserActivity from '@/components/UserActivity'
export default async function DashboardPage() {
// サーバーでデータをフェッチ
const userData = await fetchUserData()
return (
<div className="dashboard">
<h1>ダッシュボード</h1>
{/* Client Componentにデータを渡す */}
<UserActivity data={userData} />
</div>
)
}
// components/UserActivity.jsx (Client Component)
'use client'
import { useState } from 'react'
import { LineChart } from 'some-chart-library'
export default function UserActivity({ data }) {
const [selectedPeriod, setSelectedPeriod] = useState('week')
// クライアント側でデータのフィルタリング
const filteredData = filterDataByPeriod(data, selectedPeriod)
return (
<div>
<div className="controls">
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
>
<option value="day">日別</option>
<option value="week">週別</option>
<option value="month">月別</option>
</select>
</div>
{/* インタラクティブなチャート(クライアント専用) */}
<LineChart data={filteredData} />
</div>
)
}
3. 境界を適切に設計する 🧱
コンポーネント間の境界を設計する際は、パフォーマンスとコード品質のバランスを考慮することが重要です。
実装例:最適化されたブログアプリ
実際にServer ComponentsとClient Componentsを使い分けた、ブログアプリケーションの例を見てみましょう。
ディレクトリ構造
app/
├── layout.tsx # Server Component
├── page.tsx # Server Component(ブログ一覧)
├── blog/
│ └── [slug]/
│ └── page.tsx # Server Component(ブログ詳細)
├── components/
│ ├── BlogCard.tsx # Server Component
│ ├── CommentForm.tsx # Client Component
│ ├── CommentList.tsx # Server Component
│ ├── LikeButton.tsx # Client Component
│ ├── ShareButtons.tsx # Client Component
│ └── TableOfContents.tsx # Client Component
ブログ記事ページの実装
// app/blog/[slug]/page.tsx (Server Component)
import { notFound } from 'next/navigation'
import Image from 'next/image'
import { getBlogPost, getRecentComments } from '@/lib/api'
import LikeButton from '@/components/LikeButton'
import ShareButtons from '@/components/ShareButtons'
import CommentForm from '@/components/CommentForm'
import CommentList from '@/components/CommentList'
import TableOfContents from '@/components/TableOfContents'
export async function generateMetadata({ params }) {
const post = await getBlogPost(params.slug)
if (!post) return { title: 'ブログ記事が見つかりません' }
return {
title: post.title,
description: post.excerpt,
openGraph: {
images: [{ url: post.coverImage }]
}
}
}
export default async function BlogPostPage({ params }) {
const post = await getBlogPost(params.slug)
if (!post) notFound()
const comments = await getRecentComments(params.slug)
return (
<main className="container mx-auto py-10">
<article className="prose lg:prose-xl mx-auto">
<h1>{post.title}</h1>
<div className="flex items-center gap-4">
<time>{new Date(post.publishedAt).toLocaleDateString('ja-JP')}</time>
<LikeButton postId={post.id} initialLikes={post.likes} />
<ShareButtons title={post.title} url={`/blog/${post.slug}`} />
</div>
{post.coverImage && (
<div className="my-6 relative aspect-video">
<Image
src={post.coverImage}
alt={post.title}
fill
className="object-cover rounded-lg"
priority
/>
</div>
)}
<div className="flex flex-col md:flex-row gap-10">
<div className="md:w-3/4">
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</div>
<aside className="md:w-1/4">
<div className="sticky top-20">
<TableOfContents content={post.content} />
</div>
</aside>
</div>
</article>
<section className="mt-16 max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-6">コメント</h2>
<CommentList comments={comments} />
<CommentForm postId={post.id} />
</section>
</main>
)
}
Client Componentsの実装
// components/LikeButton.tsx (Client Component)
'use client'
import { useState } from 'react'
import { toast } from 'react-hot-toast'
export default function LikeButton({ postId, initialLikes }) {
const [likes, setLikes] = useState(initialLikes)
const [isLiked, setIsLiked] = useState(false)
const handleLike = async () => {
try {
setIsLiked(!isLiked)
setLikes(prev => isLiked ? prev - 1 : prev + 1)
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: isLiked ? 'unlike' : 'like' })
})
if (!response.ok) throw new Error()
} catch (error) {
// 失敗した場合は元に戻す
setIsLiked(!isLiked)
setLikes(prev => isLiked ? prev + 1 : prev - 1)
toast.error('操作に失敗しました')
}
}
return (
<button
onClick={handleLike}
className={`flex items-center gap-1 px-3 py-1 rounded-full ${
isLiked ? 'bg-red-100 text-red-600' : 'bg-gray-100'
}`}
>
{isLiked ? '❤️' : '🤍'} {likes}
</button>
)
}
// components/TableOfContents.tsx (Client Component)
'use client'
import { useEffect, useState } from 'react'
export default function TableOfContents({ content }) {
const [headings, setHeadings] = useState([])
const [activeId, setActiveId] = useState('')
// コンテンツからh2, h3タグを抽出
useEffect(() => {
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/html')
const elements = Array.from(doc.querySelectorAll('h2, h3'))
const extractedHeadings = elements.map(el => ({
id: el.id,
text: el.textContent,
level: el.tagName.toLowerCase()
}))
setHeadings(extractedHeadings)
}, [content])
// スクロール位置に応じてアクティブな見出しを特定
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
setActiveId(entry.target.id)
}
})
},
{ rootMargin: '0px 0px -80% 0px' }
)
document.querySelectorAll('h2, h3').forEach(heading => {
observer.observe(heading)
})
return () => {
observer.disconnect()
}
}, [])
return (
<nav className="toc p-4 bg-gray-50 rounded-lg">
<h3 className="text-lg font-semibold mb-3">目次</h3>
<ul className="space-y-2">
{headings.map(heading => (
<li
key={heading.id}
className={`
${heading.level === 'h3' ? 'ml-4' : ''}
${activeId === heading.id ? 'text-blue-600 font-medium' : ''}
`}
>
<a
href={`#${heading.id}`}
onClick={(e) => {
e.preventDefault()
document.getElementById(heading.id)?.scrollIntoView({
behavior: 'smooth'
})
}}
>
{heading.text}
</a>
</li>
))}
</ul>
</nav>
)
}
パフォーマンス最適化のベストプラクティス
Server ComponentsとClient Componentsを最大限に活用するためのベストプラクティスをまとめます。
1. デフォルトでServer Componentsを使用する 🚀
特に理由がない限り、まずはServer Componentとして実装し、必要な場合のみClient Componentに変更することを検討しましょう。
2. Client Componentsをできるだけ葉ノードに移動する 🍃
コンポーネントツリーの下層(葉ノード)にClient Componentsを配置することで、JavaScriptバンドルのサイズを最小限に抑えることができます。
// 良くない例(大きなコンポーネントがClient Component)
'use client'
function LargeComponent() {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<Header />
<Sidebar />
<MainContent />
<button onClick={() => setIsOpen(true)}>詳細を見る</button>
{isOpen && <Modal />}
</div>
)
}
// 良い例(インタラクティブな部分だけをClient Componentに)
// LargeComponent.jsx (Server Component)
import DetailsButton from './DetailsButton'
function LargeComponent() {
return (
<div>
<Header />
<Sidebar />
<MainContent />
<DetailsButton />
</div>
)
}
// DetailsButton.jsx (Client Component)
'use client'
import { useState } from 'react'
function DetailsButton() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>詳細を見る</button>
{isOpen && <Modal />}
</>
)
}
3. データフェッチはServer Componentsで行う 📊
データフェッチングはServer Componentsで行い、必要なデータだけをClient Componentsにpropsとして渡すことで、パフォーマンスとユーザー体験を向上させることができます。
// 良くない例(Client Componentでのデータフェッチ)
'use client'
import { useEffect, useState } from 'react'
function UserProfile() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
async function fetchUser() {
try {
const res = await fetch('/api/user')
const data = await res.json()
setUser(data)
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
fetchUser()
}, [])
if (loading) return <div>読み込み中...</div>
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
)
}
// 良い例(Server Componentでのデータフェッチ)
// ProfilePage.jsx (Server Component)
import UserProfile from './UserProfile'
export default async function ProfilePage() {
const user = await fetch('https://api.example.com/user').then(res => res.json())
return <UserProfile user={user} />
}
// UserProfile.jsx (Client Component)
'use client'
export default function UserProfile({ user }) {
// データはすでに利用可能なので、読み込み状態は不要
return (
<div>
<h1>{user.name}</h1>
<p>{user.bio}</p>
</div>
)
}
4. Suspenseを活用してストリーミングを実装する 🌊
Suspenseを使用することで、ページ全体の読み込みを待たずに、利用可能になった部分から順次表示することができます。
// app/dashboard/page.jsx
import { Suspense } from 'react'
import UserProfile from './UserProfile'
import RecentActivity from './RecentActivity'
import Statistics from './Statistics'
import LoadingSkeleton from '@/components/LoadingSkeleton'
export default function DashboardPage() {
return (
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 md:col-span-4">
<Suspense fallback={<LoadingSkeleton type="profile" />}>
<UserProfile />
</Suspense>
</div>
<div className="col-span-12 md:col-span-8">
<Suspense fallback={<LoadingSkeleton type="activity" />}>
<RecentActivity />
</Suspense>
<div className="mt-6">
<Suspense fallback={<LoadingSkeleton type="statistics" />}>
<Statistics />
</Suspense>
</div>
</div>
</div>
)
}
よくある質問と回答
Q: 既存のNext.jsプロジェクトをApp Routerに移行する際の推奨アプローチは?
A: 段階的な移行がおすすめです。新しい機能をApp Routerで開発し、既存のページは徐々に移行していきましょう。両方のルーターは共存できるため、一度にすべてを移行する必要はありません。
Q: Server ComponentsでAPIを呼び出す際のエラーハンドリングはどうすべき?
A: try/catchブロックを使用し、エラー発生時は適切なフォールバックUIを表示するか、Next.jsのerror.js
やnot-found.js
を活用してエラーページをカスタマイズすることができます。
// app/profile/page.jsx
import { notFound } from 'next/navigation'
export default async function ProfilePage() {
try {
const user = await fetchUser()
if (!user) return notFound()
return <UserProfile user={user} />
} catch (error) {
// エラーはNext.jsのエラーバウンダリでキャッチされる
throw new Error('ユーザー情報の取得に失敗しました')
}
}
Q: Server ComponentsとClient Componentsを混在させる際のパフォーマンスへの影響は?
A: 適切に設計すれば、パフォーマンスに悪影響はありません。Client Componentsをツリーの下層に配置し、必要な部分だけをインタラクティブにすることで、JavaScript量を最小限に抑えつつ、必要な機能を実現できます。
Q: useEffectを使ったデータフェッチングと、Server Componentsでのデータフェッチング、どちらを使うべき?
A: 基本的にはServer Componentsでのデータフェッチングが推奨されます。サーバー側でフェッチすることで、初期ロード時のパフォーマンスが向上し、SEOにも有利です。ただし、ユーザーインタラクションに応じて動的にデータを取得する場合は、Client ComponentsでのuseEffect/SWRを使用することも適切です。
まとめ
Server ComponentsとClient Componentsは、それぞれに強みと弱みがあります。効果的に使い分けることで、パフォーマンスとユーザー体験を最大化できます。
覚えておくべき重要なポイント:
- Server Componentsをデフォルトで使用する
- インタラクティブな部分だけをClient Componentsにする
- Client Componentsはコンポーネントツリーの下層に配置する
- データフェッチングはServer Componentsで行う
- 可能な限りJavaScriptバンドルを小さく保つ
Next.js App Routerは、Server ComponentsとClient Componentsの強みを最大限に活かせる優れたフレームワークです。今回紹介したパターンやベストプラクティスを活用して、高速でインタラクティブなWebアプリケーションを構築してください!
最後に:業務委託のご相談を承ります
私は業務委託エンジニアとしてWEB制作やシステム開発を請け負っています。最新技術を活用したレスポンシブなWebサイト制作、インタラクティブなアプリケーション開発、API連携など幅広いご要望に対応可能です。
「課題解決に向けた即戦力が欲しい」「高品質なWeb制作を依頼したい」という方は、お気軽にご相談ください。一緒にビジネスの成長を目指しましょう!
👉 ポートフォリオ
🌳 らくらくサイト