0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🔄 React Server ComponentsとClient Components完全解説 - Next.jsアプリのパフォーマンスを最大化する使い分け術

Last updated at Posted at 2025-03-10

🔄 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アプリケーションを設計できるようになるはずです!

📋 目次

  1. Server ComponentsとClient Componentsの基本
  2. レンダリングの仕組み
  3. それぞれのコンポーネントの使いどころ
  4. コンポーネント間の連携パターン
  5. 実装例:最適化されたブログアプリ
  6. パフォーマンス最適化のベストプラクティス
  7. よくある質問と回答
  8. まとめ

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でのレンダリングプロセスを理解することは、両コンポーネントの使い分けを考える上で重要です。次の図は、ページが最初に読み込まれる際のレンダリングフローを示しています:

レンダリングは次の段階で進みます:

  1. サーバーサイド:
    • Reactは、Server Componentsを「RSC Payload」と呼ばれる特殊なデータ形式にレンダリング
    • Next.jsは、RSC PayloadとClient Component用のJavaScriptを使用してHTMLを生成
  2. クライアントサイド:
    • 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.jsnot-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制作を依頼したい」という方は、お気軽にご相談ください。一緒にビジネスの成長を目指しましょう!

👉 ポートフォリオ

🌳 らくらくサイト

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?