WithCodeMedia-1-pc
previous arrowprevious arrow
next arrownext arrow

WithCodeMedia-1-sp
previous arrowprevious arrow
next arrownext arrow

【完全保存版】Next.jsサーバーコンポーネントの状態管理を徹底解説!基本から応用まで実装例を踏まえて詳しく解説

この記事でわかること

  • サーバーコンポーネントで useState が使えない理由と、代わりの設計思想
  • データフェッチング・props受け渡し・Server Actions による5つの実装パターン
  • Context API・Zustand など、グローバル状態管理の正しい使い方
  • 並列フェッチング・Suspense によるパフォーマンス最適化の方法
  • よくあるエラーと、その具体的な解決策
生徒

Next.jsのサーバーコンポーネントで状態管理ってどうやるんですか?useStateが使えないって聞いたんですが…

ペン博士

よーく聞くんだぞ!サーバーコンポーネントとクライアントコンポーネントでは状態管理の考え方が全く違うんじゃ。今日はその違いから実践的な実装方法まで詳しく解説するぞい!

結論から言うと、Next.js のサーバーコンポーネントに「状態管理」は存在しません。代わりに「データフェッチング」と「props の受け渡し」でアプリを組み立てるのが正解です。インタラクティブな操作が必要な箇所だけをクライアントコンポーネントに切り出し、Server Actions でデータを変更する設計が、App Router 時代のベストプラクティスです。

本記事では、Next.js のサーバーコンポーネントにおける状態管理の考え方から実装パターン、クライアントコンポーネントとの使い分けまで、実践的な実装例を交えて徹底解説します。


目次

Next.js サーバーコンポーネントとは?

サーバーコンポーネントの基本概念

サーバーコンポーネント(React Server Components)は、サーバー側でのみレンダリングされ、JavaScript バンドルに含まれない React コンポーネントです。従来の SSR とは異なり、コンポーネント単位でサーバー・クライアントを分けられるのが特徴です。

Next.js 13 の App Router では、デフォルトですべてのコンポーネントがサーバーコンポーネントとして扱われます。主なメリットは以下のとおりです。

  • バンドルサイズの削減:サーバーでレンダリングされるため、クライアントに送る JavaScript が大幅に減る
  • データベースへの直接アクセス:ORM や秘密情報をクライアントに露出せずサーバー側で処理できる
  • 初期表示の高速化:完全な HTML をサーバーから返すため FCP が向上する
  • SEO 最適化:クローラーが HTML 全体を取得できる

サーバーコンポーネントとクライアントコンポーネントの違い

特徴サーバーコンポーネントクライアントコンポーネント
レンダリング場所サーバー側のみサーバー+クライアント
状態管理useState・useEffect 等は使用不可すべての React Hooks が使用可能
イベントハンドラ使用不可使用可能(onClick 等)
バンドルサイズクライアントに含まれないクライアントに含まれる
データアクセスDB 直接アクセス可能API エンドポイント経由
宣言方法デフォルト(特別な宣言不要)ファイル先頭に 'use client'

サーバーコンポーネントにおける状態管理の考え方

生徒

サーバーコンポーネントで状態管理ができないなら、どうやってデータを扱えば良いんですか?

ペン博士

良い質問じゃ!サーバーコンポーネントでは「状態」という概念ではなく、「データフェッチング」と「propsの受け渡し」で考えるんじゃよ

サーバーコンポーネントでは「状態」ではなく「データ」を扱う

最も重要な考え方の転換は、「状態管理」から「データ管理」へのシフトです。サーバーコンポーネントでは次の3ステップで考えます。

  1. データはサーバーで取得する:DB や API から必要なデータをサーバー側で取得
  2. props として子コンポーネントに渡す:取得したデータを下位コンポーネントへ受け渡す
  3. インタラクティブな部分のみクライアントコンポーネント化:ユーザー操作が必要な箇所だけを切り出す

サーバーコンポーネントでできること・できないこと

✅ サーバーコンポーネントでできること

  • async/await を使った非同期データフェッチング
  • データベースへの直接アクセス(Prisma・Drizzle 等の ORM)
  • サーバー側のみの環境変数・秘密情報へのアクセス
  • fs・path 等の Node.js 標準モジュールの使用
  • 重い計算処理(クライアントのパフォーマンスに影響しない)

❌ サーバーコンポーネントでできないこと

  • useState・useReducer 等の状態管理フック
  • useEffect・useLayoutEffect 等の副作用フック
  • onClick・onChange 等のイベントハンドラ
  • window・document・localStorage 等のブラウザ API
  • クライアント専用のサードパーティライブラリ

サーバーコンポーネントでのデータ管理パターン

パターン1:サーバーコンポーネントでのデータフェッチング

最も基本的なパターンです。サーバーコンポーネント内で直接データを取得し、そのままレンダリングします。

// app/posts/page.tsx(サーバーコンポーネント)
import { db } from '@/lib/database'

// 非同期コンポーネントとして定義
async function PostsPage() {
  // サーバー側でデータを取得
  const posts = await db.post.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10
  })

  return (
    <div>
      <h1>最新の投稿</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default PostsPage

ポイントは3点です。

  1. async 関数として定義:サーバーコンポーネントは非同期関数にできる
  2. await でデータ取得:DB から直接データを取得して結果を待つ
  3. 取得したデータを直接レンダリング:状態管理を介さず JSX でそのまま使う

パターン2:props によるデータの受け渡し

サーバーコンポーネント間でデータを共有する場合は、props を使って受け渡します。

// app/posts/[id]/page.tsx(親サーバーコンポーネント)
import { db } from '@/lib/database'
import PostDetail from '@/components/PostDetail'
import RelatedPosts from '@/components/RelatedPosts'

async function PostPage({ params }: { params: { id: string } }) {
  // 投稿データを取得
  const post = await db.post.findUnique({
    where: { id: params.id },
    include: { author: true, category: true }
  })

  if (!post) {
    return <div>投稿が見つかりません</div>
  }

  // 関連投稿も取得
  const relatedPosts = await db.post.findMany({
    where: {
      categoryId: post.categoryId,
      id: { not: post.id }
    },
    take: 5
  })

  return (
    <div>
      {/* propsとしてデータを渡す */}
      <PostDetail post={post} />
      <RelatedPosts posts={relatedPosts} />
    </div>
  )
}

export default PostPage
// components/PostDetail.tsx(子サーバーコンポーネント)
type Post = {
  id: string
  title: string
  content: string
  author: {
    name: string
    avatar: string
  }
  createdAt: Date
}

function PostDetail({ post }: { post: Post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <div>
        <img src={post.author.avatar} alt={post.author.name} />
        <span>{post.author.name}</span>
        <time>{post.createdAt.toLocaleDateString()}</time>
      </div>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

export default PostDetail

このパターンにより、データ取得のロジックと表示ロジックを分離でき、コンポーネントの再利用性が高まります。

パターン3:Server Actions を使ったデータ変更

データの作成・更新・削除が必要な場合は Server Actions を使用します。API エンドポイントを別途作成せずに、型安全にサーバー側処理を実行できます。

// app/actions/post.ts(Server Actions)
'use server'

import { db } from '@/lib/database'
import { revalidatePath } from 'next/cache'

// 投稿を作成するアクション
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const authorId = formData.get('authorId') as string

  // バリデーション
  if (!title || !content) {
    return { error: 'タイトルと内容は必須です' }
  }

  try {
    // データベースに保存
    const post = await db.post.create({
      data: {
        title,
        content,
        authorId
      }
    })

    // キャッシュを更新
    revalidatePath('/posts')

    return { success: true, post }
  } catch (error) {
    return { error: '投稿の作成に失敗しました' }
  }
}
// components/PostForm.tsx(クライアントコンポーネント)
'use client'

import { createPost } from '@/app/actions/post'
import { useState } from 'react'

function PostForm({ authorId }: { authorId: string }) {
  const [message, setMessage] = useState('')

  async function handleSubmit(formData: FormData) {
    const result = await createPost(formData)

    if (result.error) {
      setMessage(result.error)
    } else {
      setMessage('投稿を作成しました!')
    }
  }

  return (
    <form action={handleSubmit}>
      <input type="hidden" name="authorId" value={authorId} />

      <div>
        <label htmlFor="title">タイトル</label>
        <input type="text" id="title" name="title" required />
      </div>

      <div>
        <label htmlFor="content">内容</label>
        <textarea id="content" name="content" required />
      </div>

      <button type="submit">投稿する</button>

      {message && <p>{message}</p>}
    </form>
  )
}

export default PostForm

Server Actions の仕組みは以下のとおりです。

  1. ‘use server’ ディレクティブ:ファイル先頭に記述することで、その関数がサーバーでのみ実行されることを示す
  2. フォームデータの処理:FormData オブジェクトから値を取得し、バリデーションを行う
  3. データベース操作:サーバー側で DB に直接アクセスして操作を実行
  4. revalidatePath:指定パスのキャッシュを無効化し、最新データが表示されるようにする
  5. クライアントコンポーネントから呼び出し:’use client’ コンポーネントから Server Actions を呼び出して結果に応じた状態管理を行う

この方法により、API エンドポイントを作成せずに、型安全にサーバー側の処理を実行できます


クライアントコンポーネントとの連携パターン

生徒

ユーザーの操作に応じて表示を変えたい場合はどうすれば良いんですか?

ペン博士

そういう時はクライアントコンポーネントの出番じゃ!サーバーコンポーネントとクライアントコンポーネントを適切に組み合わせるのがポイントなんじゃよ

パターン4:サーバーコンポーネントからクライアントコンポーネントへの props 渡し

インタラクティブな機能が必要な部分だけをクライアントコンポーネント化し、サーバーコンポーネントから props でデータを渡すパターンです。

// app/dashboard/page.tsx(サーバーコンポーネント)
import { db } from '@/lib/database'
import { auth } from '@/lib/auth'
import StatisticsChart from '@/components/StatisticsChart'
import TodoList from '@/components/TodoList'

async function DashboardPage() {
  // 認証情報を取得
  const session = await auth()

  if (!session?.user) {
    redirect('/login')
  }

  // サーバー側でデータを取得
  const [statistics, todos] = await Promise.all([
    db.statistics.findMany({
      where: { userId: session.user.id },
      orderBy: { date: 'desc' },
      take: 30
    }),
    db.todo.findMany({
      where: { userId: session.user.id, completed: false },
      orderBy: { priority: 'desc' }
    })
  ])

  return (
    <div>
      <h1>ダッシュボード</h1>

      {/* サーバーで取得したデータをクライアントコンポーネントに渡す */}
      <StatisticsChart data={statistics} />
      <TodoList initialTodos={todos} userId={session.user.id} />
    </div>
  )
}

export default DashboardPage
// components/StatisticsChart.tsx(クライアントコンポーネント)
'use client'

import { useState } from 'react'
import { Line } from 'react-chartjs-2'

type Statistics = {
  date: Date
  value: number
}

function StatisticsChart({ data }: { data: Statistics[] }) {
  const [period, setPeriod] = useState<'week' | 'month'>('week')

  // データを期間でフィルタリング
  const filteredData = data.filter((item) => {
    const daysDiff = Math.floor(
      (Date.now() - item.date.getTime()) / (1000 * 60 * 60 * 24)
    )
    return period === 'week' ? daysDiff <= 7 : daysDiff <= 30
  })

  // チャート用のデータ形式に変換
  const chartData = {
    labels: filteredData.map((item) =>
      item.date.toLocaleDateString('ja-JP')
    ),
    datasets: [
      {
        label: '統計データ',
        data: filteredData.map((item) => item.value),
        borderColor: 'rgb(75, 192, 192)',
        tension: 0.1
      }
    ]
  }

  return (
    <div>
      <div>
        <button
          onClick={() => setPeriod('week')}
          className={period === 'week' ? 'active' : ''}
        >
          週間
        </button>
        <button
          onClick={() => setPeriod('month')}
          className={period === 'month' ? 'active' : ''}
        >
          月間
        </button>
      </div>

      <Line data={chartData} />
    </div>
  )
}

export default StatisticsChart

この設計により、初期データの取得はサーバー側で高速に行い、ユーザー操作による表示変更はクライアント側でスムーズに処理できます。

パターン5:クライアントコンポーネントでの状態管理と Server Actions の組み合わせ

より複雑な状態管理には、クライアントコンポーネントで useState・useReducer を使いつつ、データの永続化は Server Actions で行います。

// components/TodoList.tsx(クライアントコンポーネント)
'use client'

import { useState, useOptimistic } from 'react'
import { toggleTodo, deleteTodo } from '@/app/actions/todo'

type Todo = {
  id: string
  title: string
  completed: boolean
}

function TodoList({
  initialTodos,
  userId
}: {
  initialTodos: Todo[]
  userId: string
}) {
  // ローカル状態でTODOリストを管理
  const [todos, setTodos] = useState(initialTodos)

  // 楽観的更新のための状態
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo: Todo) => [...state, newTodo]
  )

  // TODO完了/未完了の切り替え
  async function handleToggle(id: string) {
    // 楽観的にUIを更新
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    )

    // サーバーに変更を保存
    const result = await toggleTodo(id)

    if (result.error) {
      // エラーの場合は元に戻す
      setTodos((prev) =>
        prev.map((todo) =>
          todo.id === id ? { ...todo, completed: !todo.completed } : todo
        )
      )
      alert(result.error)
    }
  }

  // TODO削除
  async function handleDelete(id: string) {
    // 楽観的にUIから削除
    setTodos((prev) => prev.filter((todo) => todo.id !== id))

    // サーバーで削除
    const result = await deleteTodo(id)

    if (result.error) {
      // エラーの場合は元に戻す(本来は削除前の状態を保持すべき)
      alert(result.error)
    }
  }

  return (
    <div>
      <h2>TODOリスト</h2>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => handleToggle(todo.id)}
            />
            <span style={{
              textDecoration: todo.completed ? 'line-through' : 'none'
            }}>
              {todo.title}
            </span>
            <button onClick={() => handleDelete(todo.id)}>
              削除
            </button>
          </li>
        ))}
      </ul>
    </div>
  )
}

export default TodoList
// app/actions/todo.ts(Server Actions)
'use server'

import { db } from '@/lib/database'
import { revalidatePath } from 'next/cache'

export async function toggleTodo(id: string) {
  try {
    const todo = await db.todo.findUnique({ where: { id } })

    if (!todo) {
      return { error: 'TODOが見つかりません' }
    }

    await db.todo.update({
      where: { id },
      data: { completed: !todo.completed }
    })

    revalidatePath('/dashboard')
    return { success: true }
  } catch (error) {
    return { error: '更新に失敗しました' }
  }
}

export async function deleteTodo(id: string) {
  try {
    await db.todo.delete({ where: { id } })
    revalidatePath('/dashboard')
    return { success: true }
  } catch (error) {
    return { error: '削除に失敗しました' }
  }
}

高度なテクニックのポイントです。

  1. useOptimistic フック:React 19 で導入された楽観的更新を実現するフック。サーバーの応答を待たずに UI を即座に更新できる
  2. 楽観的更新の実装:チェックボックスをクリックした瞬間に UI を更新し、バックグラウンドでサーバーに保存
  3. エラーハンドリング:サーバー側でエラーが発生した場合は UI を元の状態に戻す
  4. Server Actions との連携:クライアント側の状態管理とサーバー側のデータ永続化をシームレスに連携

この方法により、ユーザーにとって快適な操作感を保ちながら、データの整合性も担保できます。


グローバル状態管理の戦略

サーバーコンポーネントとクライアントコンポーネントが混在する環境では、グローバル状態管理の戦略も変わります。

パターン6:Context API の使用(クライアントコンポーネント専用)

Context API はクライアントコンポーネント間でのみ使用できます。

// contexts/ThemeContext.tsx(クライアントコンポーネント)
'use client'

import { createContext, useContext, useState, ReactNode } from 'react'

type Theme = 'light' | 'dark'

type ThemeContextType = {
  theme: Theme
  toggleTheme: () => void
}

const ThemeContext = createContext<ThemeContextType | undefined>(undefined)

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light')

  const toggleTheme = () => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
  }

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (context === undefined) {
    throw new Error('useTheme must be used within a ThemeProvider')
  }
  return context
}
// app/layout.tsx(ルートレイアウト)
import { ThemeProvider } from '@/contexts/ThemeContext'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </body>
    </html>
  )
}
// components/ThemeToggle.tsx(クライアントコンポーネント)
'use client'

import { useTheme } from '@/contexts/ThemeContext'

function ThemeToggle() {
  const { theme, toggleTheme } = useTheme()

  return (
    <button onClick={toggleTheme}>
      {theme === 'light' ? '🌙 ダークモード' : '☀️ ライトモード'}
    </button>
  )
}

export default ThemeToggle

Context API を使う際の注意点です。

  1. Provider はクライアントコンポーネント:ThemeProvider は ‘use client’ を使ったクライアントコンポーネント
  2. ルートレイアウトに配置:アプリ全体で使用できるよう、ルートレイアウトに配置する
  3. children はサーバーコンポーネントでも可:Provider の子要素にはサーバーコンポーネントも配置できる
  4. useContext はクライアントコンポーネントのみ:useTheme フックはクライアントコンポーネント内でのみ使用可能

パターン7:Zustand を使った状態管理

より複雑な状態管理には、Zustand のような軽量ライブラリが便利です。Provider の設定が不要で、シンプルな API が特徴です。

// stores/useCartStore.ts
import { create } from 'zustand'

type CartItem = {
  id: string
  name: string
  price: number
  quantity: number
}

type CartStore = {
  items: CartItem[]
  addItem: (item: Omit<CartItem, 'quantity'>) => void
  removeItem: (id: string) => void
  updateQuantity: (id: string, quantity: number) => void
  clearCart: () => void
  total: number
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],

  addItem: (item) =>
    set((state) => {
      const existingItem = state.items.find((i) => i.id === item.id)

      if (existingItem) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        }
      }

      return {
        items: [...state.items, { ...item, quantity: 1 }],
      }
    }),

  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),

  updateQuantity: (id, quantity) =>
    set((state) => ({
      items: state.items.map((item) =>
        item.id === id ? { ...item, quantity } : item
      ),
    })),

  clearCart: () => set({ items: [] }),

  get total() {
    return get().items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    )
  },
}))
// components/Cart.tsx(クライアントコンポーネント)
'use client'

import { useCartStore } from '@/stores/useCartStore'

function Cart() {
  const { items, removeItem, updateQuantity, total } = useCartStore()

  return (
    <div>
      <h2>ショッピングカート</h2>

      {items.length === 0 ? (
        <p>カートは空です</p>
      ) : (
        <>
          <ul>
            {items.map((item) => (
              <li key={item.id}>
                <h3>{item.name}</h3>
                <p>¥{item.price.toLocaleString()}</p>

                <input
                  type="number"
                  value={item.quantity}
                  onChange={(e) =>
                    updateQuantity(item.id, parseInt(e.target.value))
                  }
                  min="1"
                />

                <button onClick={() => removeItem(item.id)}>
                  削除
                </button>
              </li>
            ))}
          </ul>

          <div>
            <strong>合計: ¥{total.toLocaleString()}</strong>
          </div>
        </>
      )}
    </div>
  )
}

export default Cart

Zustand のメリットは以下のとおりです。

  • シンプルな API:Provider の設定が不要で、フックを直接使える
  • TypeScript 完全対応:型安全な状態管理が可能
  • パフォーマンス最適化:必要な部分だけ再レンダリングされる
  • DevTools 対応:デバッグが容易

ただし、Zustand もクライアントコンポーネント専用であることに注意してください。


パフォーマンス最適化のベストプラクティス

1. データフェッチングの最適化

並列データフェッチング

// ❌ 悪い例:直列でデータを取得
async function BadExample() {
  const user = await fetchUser()
  const posts = await fetchPosts() // userの取得を待ってから実行
  const comments = await fetchComments() // postsの取得を待ってから実行

  return <div>...</div>
}

// ✅ 良い例:並列でデータを取得
async function GoodExample() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
  ])

  return <div>...</div>
}

キャッシュの活用

// Next.js 13以降のfetch APIは自動的にキャッシュされる
async function fetchData() {
  const res = await fetch('https://api.example.com/data', {
    // キャッシュの動作を指定
    next: { revalidate: 3600 } // 1時間ごとに再検証
  })
  return res.json()
}

// キャッシュを無効にする場合
async function fetchFreshData() {
  const res = await fetch('https://api.example.com/data', {
    cache: 'no-store' // 常に最新のデータを取得
  })
  return res.json()
}

2. コンポーネント分割の最適化

できるだけ多くの部分をサーバーコンポーネントとして保ち、必要最小限の部分だけをクライアントコンポーネント化します。

// ❌ 悪い例:ページ全体をクライアントコンポーネント化
'use client'

export default function Page() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Header /> {/* 静的なのにクライアントコンポーネントになる */}
      <Article /> {/* 静的なのにクライアントコンポーネントになる */}
      <button onClick={() => setCount(count + 1)}>{count}</button>
      <Footer /> {/* 静的なのにクライアントコンポーネントになる */}
    </div>
  )
}

// ✅ 良い例:インタラクティブな部分だけクライアントコンポーネント化
// page.tsx(サーバーコンポーネント)
export default function Page() {
  return (
    <div>
      <Header /> {/* サーバーコンポーネント */}
      <Article /> {/* サーバーコンポーネント */}
      <Counter /> {/* クライアントコンポーネント */}
      <Footer /> {/* サーバーコンポーネント */}
    </div>
  )
}

// Counter.tsx(クライアントコンポーネント)
'use client'

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

3. Suspense とストリーミングの活用

Suspense を使うことで、データの読み込み中もページの一部を先に表示できます。

// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserProfile from '@/components/UserProfile'
import RecentActivity from '@/components/RecentActivity'
import Statistics from '@/components/Statistics'

export default function Dashboard() {
  return (
    <div>
      {/* 高速に表示できる部分 */}
      <h1>ダッシュボード</h1>

      {/* 各コンポーネントを個別にSuspenseで囲む */}
      <Suspense fallback={<UserProfileSkeleton />}>
        <UserProfile />
      </Suspense>

      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>

      <Suspense fallback={<StatisticsSkeleton />}>
        <Statistics />
      </Suspense>
    </div>
  )
}

この方法により、各コンポーネントが独立してストリーミングされ、ユーザーは待ち時間を感じにくくなります


実践的なアーキテクチャ設計

推奨ディレクトリ構造

src/
├── app/                    # App Router
│   ├── layout.tsx         # ルートレイアウト
│   ├── page.tsx           # ホームページ(サーバーコンポーネント)
│   ├── dashboard/
│   │   ├── page.tsx       # ダッシュボード(サーバーコンポーネント)
│   │   └── loading.tsx    # ローディング状態
│   └── actions/           # Server Actions
│       ├── post.ts
│       └── user.ts
├── components/
│   ├── server/            # サーバーコンポーネント専用
│   │   ├── Header.tsx
│   │   └── Footer.tsx
│   ├── client/            # クライアントコンポーネント専用
│   │   ├── Counter.tsx
│   │   └── Form.tsx
│   └── shared/            # 両方で使える
│       └── Button.tsx
├── lib/
│   ├── database.ts        # データベース接続
│   └── utils.ts           # ユーティリティ関数
└── stores/                # クライアント側状態管理
    └── useCartStore.ts

設計原則

  1. デフォルトはサーバーコンポーネント:特別な理由がない限り、サーバーコンポーネントとして実装する
  2. クライアントコンポーネントは最小限:インタラクティブな機能が必要な部分のみクライアントコンポーネント化する
  3. データ取得はサーバーで:DB や API へのアクセスはサーバーコンポーネントで行う
  4. Server Actions でデータ変更:作成・更新・削除は Server Actions を使用する
  5. 状態はクライアントで:ユーザー操作に応じた一時的な状態はクライアントコンポーネントで管理する

よくあるトラブルと解決法

トラブル1:「useState can only be used in Client Components」エラー

原因:サーバーコンポーネントで useState 等の React Hooks を使用しようとしている

解決法

  1. コンポーネントファイルの先頭に ‘use client’ を追加する
  2. インタラクティブな部分を別のクライアントコンポーネントとして切り出す

トラブル2:サーバーコンポーネントからクライアントコンポーネントに関数を渡せない

原因:サーバーコンポーネントで定義した関数を props としてクライアントコンポーネントに渡そうとしている

解決法:Server Actions を使用するか、関数をクライアントコンポーネント内で定義する

// ❌ 悪い例
// page.tsx(サーバーコンポーネント)
export default function Page() {
  const handleClick = () => {
    console.log('clicked')
  }

  return <ClientButton onClick={handleClick} /> // エラー
}

// ✅ 良い例:Server Actionsを使用
// page.tsx(サーバーコンポーネント)
import { myAction } from '@/app/actions'
import ClientButton from '@/components/ClientButton'

export default function Page() {
  return <ClientButton action={myAction} />
}

// ClientButton.tsx
'use client'

export default function ClientButton({ action }: { action: () => Promise<void> }) {
  return <button onClick={() => action()}>クリック</button>
}

トラブル3:データが更新されない

原因:Next.js のキャッシュが原因でデータが更新されない

解決法:revalidatePath や revalidateTag を使ってキャッシュを無効化する

// Server Action内でキャッシュを無効化
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string, data: PostData) {
  await db.post.update({ where: { id }, data })

  // 特定のパスのキャッシュを無効化
  revalidatePath('/posts')
  revalidatePath(`/posts/${id}`)

  // または、タグを使ってキャッシュを無効化
  revalidateTag('posts')
}

生徒

サーバーコンポーネントとクライアントコンポーネントの使い分け、だんだんわかってきました!

ペン博士

その調子じゃ!要は「データの取得はサーバー、ユーザー操作への反応はクライアント」と覚えておけば良いんじゃよ


よくある質問

サーバーコンポーネントで useEffect を使うにはどうすればいいですか?

useEffect はサーバーコンポーネントで使用できません。副作用処理が必要な場合は、そのコンポーネントをクライアントコンポーネント(’use client’)に変更するか、インタラクティブな部分のみを切り出した子クライアントコンポーネントを作成してください。サーバー側での初期化処理は async 関数として直接記述できます。

Server Actions と API Routes はどう使い分けますか?

Next.js アプリ内の操作には Server Actions を使うのが現在のベストプラクティスです。API Routes は外部サービスや別ドメインのクライアントからアクセスされる場合、または WebSocket・SSE を使うケースで引き続き有効です。App Router 専用のアプリであれば、基本的に Server Actions で完結します。

Zustand の状態をサーバーコンポーネントから初期化できますか?

直接はできません。サーバーコンポーネントで取得したデータをクライアントコンポーネントに props として渡し、クライアントコンポーネントの初期化処理(useEffect や initialState)で Zustand ストアにセットする方法が一般的です。

useOptimistic は React 何バージョン以降で使えますか?

useOptimistic は React 19 で安定版として提供されています。Next.js 15 以降では標準で使用可能です。React 18 では実験的フラグが必要なため、本番環境では Next.js のバージョンを確認してください。

Context API と Zustand はどちらを選ぶべきですか?

テーマ・ロケール・認証情報など更新頻度が低いグローバル設定には Context API が適しています。カートや複雑な UI 状態のように、頻繁に更新されたり複数コンポーネントで参照したりするデータには、再レンダリング最適化が優れた Zustand を選ぶのが無難です。


まとめ

本記事では、Next.js のサーバーコンポーネントにおける状態管理について、基本から応用まで解説しました。

  • サーバーコンポーネントでは「状態管理」ではなく「データ管理」の考え方にシフトする
  • データの取得はサーバーコンポーネントで行い、props で子コンポーネントに渡す
  • インタラクティブな機能が必要な部分のみクライアントコンポーネント化する
  • データの変更には Server Actions を使用し、API エンドポイント不要で型安全に実装できる
  • グローバル状態管理は必要に応じて Context API や Zustand を使用する(クライアント側のみ)
  • パフォーマンス最適化には並列フェッチング・適切なキャッシュ・Suspense を活用する
  • クライアントコンポーネントは最小限にとどめ、バンドルサイズを削減する

サーバーコンポーネントとクライアントコンポーネントを適切に使い分けることで、ユーザー体験とパフォーマンスを両立した、プロフェッショナルな Next.js アプリケーション開発が可能になります。


WithCodeを体験できる初級コース公開中!

WithCode初級コース

初級コース(¥49,800)が完全無料に!

  • 期間:1週間
  • 学習内容:ロードマップ/基礎知識/環境構築/HTML/CSS/LP・ポートフォリオ作成
    → 正しい学習方法で「確かな成長」を実感できるカリキュラム

副業・フリーランスが主流になっている今こそ、自らのスキルで稼げる人材を目指してみませんか?

未経験でも心配することはありません。初級コースを受講される方の大多数はプログラミング未経験です。まずは無料カウンセリングで、悩みや不安をお聞かせください!

この記事を書いた人

WithCode(ウィズコード)は「目指すなら稼げる人材」をビジョンに、累計400名以上のフリーランスを輩出してきた超実践型プログラミングスクールです。150社以上の実案件支援を特徴にWeb制作・Webデザインなどの役立つ情報を現場のノウハウに基づいて発信していきます。

– service –WithGroupの運営サービス

  • WithCode
    - ウィズコード -

    スクール

    「未経験」から
    現場で通用する
    スキルを身に付けよう!

    詳細はこちら
  • WithFree
    - ウィズフリ -

    実案件サポート

    制作会社のサポート下で
    実務経験を積んでいこう!

    詳細はこちら
  • WithCareer
    - ウィズキャリ -

    就転職サポート

    大手エージェントのサポート下で
    キャリアアップを目指そう!

    詳細はこちら

公式サイト より
今すぐ
無料カウンセリング
予約!

目次