



WithCodeMedia-1-pc
WithCodeMedia-2-pc
WithCodeMedia-3-pc
WithCodeMedia-4-pc




WithCodeMedia-1-sp
WithCodeMedia-2-sp
WithCodeMedia-3-sp
WithCodeMedia-4-sp








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



よーく聞くんだぞ!サーバーコンポーネントとクライアントコンポーネントでは状態管理の考え方が全く違うんじゃ。今日はその違いから実践的な実装方法まで詳しく解説するぞい!
結論から言うと、Next.js のサーバーコンポーネントに「状態管理」は存在しません。代わりに「データフェッチング」と「props の受け渡し」でアプリを組み立てるのが正解です。インタラクティブな操作が必要な箇所だけをクライアントコンポーネントに切り出し、Server Actions でデータを変更する設計が、App Router 時代のベストプラクティスです。
本記事では、Next.js のサーバーコンポーネントにおける状態管理の考え方から実装パターン、クライアントコンポーネントとの使い分けまで、実践的な実装例を交えて徹底解説します。
サーバーコンポーネント(React Server Components)は、サーバー側でのみレンダリングされ、JavaScript バンドルに含まれない React コンポーネントです。従来の SSR とは異なり、コンポーネント単位でサーバー・クライアントを分けられるのが特徴です。
Next.js 13 の App Router では、デフォルトですべてのコンポーネントがサーバーコンポーネントとして扱われます。主なメリットは以下のとおりです。
| 特徴 | サーバーコンポーネント | クライアントコンポーネント |
|---|---|---|
| レンダリング場所 | サーバー側のみ | サーバー+クライアント |
| 状態管理 | useState・useEffect 等は使用不可 | すべての React Hooks が使用可能 |
| イベントハンドラ | 使用不可 | 使用可能(onClick 等) |
| バンドルサイズ | クライアントに含まれない | クライアントに含まれる |
| データアクセス | DB 直接アクセス可能 | API エンドポイント経由 |
| 宣言方法 | デフォルト(特別な宣言不要) | ファイル先頭に 'use client' |



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



良い質問じゃ!サーバーコンポーネントでは「状態」という概念ではなく、「データフェッチング」と「propsの受け渡し」で考えるんじゃよ
最も重要な考え方の転換は、「状態管理」から「データ管理」へのシフトです。サーバーコンポーネントでは次の3ステップで考えます。
最も基本的なパターンです。サーバーコンポーネント内で直接データを取得し、そのままレンダリングします。
// 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点です。
サーバーコンポーネント間でデータを共有する場合は、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
このパターンにより、データ取得のロジックと表示ロジックを分離でき、コンポーネントの再利用性が高まります。
データの作成・更新・削除が必要な場合は 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 の仕組みは以下のとおりです。
この方法により、API エンドポイントを作成せずに、型安全にサーバー側の処理を実行できます。



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



そういう時はクライアントコンポーネントの出番じゃ!サーバーコンポーネントとクライアントコンポーネントを適切に組み合わせるのがポイントなんじゃよ
インタラクティブな機能が必要な部分だけをクライアントコンポーネント化し、サーバーコンポーネントから 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
この設計により、初期データの取得はサーバー側で高速に行い、ユーザー操作による表示変更はクライアント側でスムーズに処理できます。
より複雑な状態管理には、クライアントコンポーネントで 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: '削除に失敗しました' }
}
}
高度なテクニックのポイントです。
この方法により、ユーザーにとって快適な操作感を保ちながら、データの整合性も担保できます。
サーバーコンポーネントとクライアントコンポーネントが混在する環境では、グローバル状態管理の戦略も変わります。
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 を使う際の注意点です。
より複雑な状態管理には、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 のメリットは以下のとおりです。
ただし、Zustand もクライアントコンポーネント専用であることに注意してください。
// ❌ 悪い例:直列でデータを取得
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()
}
できるだけ多くの部分をサーバーコンポーネントとして保ち、必要最小限の部分だけをクライアントコンポーネント化します。
// ❌ 悪い例:ページ全体をクライアントコンポーネント化
'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>
}
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
原因:サーバーコンポーネントで useState 等の React Hooks を使用しようとしている
解決法:
原因:サーバーコンポーネントで定義した関数を 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>
}
原因: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 はサーバーコンポーネントで使用できません。副作用処理が必要な場合は、そのコンポーネントをクライアントコンポーネント(’use client’)に変更するか、インタラクティブな部分のみを切り出した子クライアントコンポーネントを作成してください。サーバー側での初期化処理は async 関数として直接記述できます。
Next.js アプリ内の操作には Server Actions を使うのが現在のベストプラクティスです。API Routes は外部サービスや別ドメインのクライアントからアクセスされる場合、または WebSocket・SSE を使うケースで引き続き有効です。App Router 専用のアプリであれば、基本的に Server Actions で完結します。
直接はできません。サーバーコンポーネントで取得したデータをクライアントコンポーネントに props として渡し、クライアントコンポーネントの初期化処理(useEffect や initialState)で Zustand ストアにセットする方法が一般的です。
useOptimistic は React 19 で安定版として提供されています。Next.js 15 以降では標準で使用可能です。React 18 では実験的フラグが必要なため、本番環境では Next.js のバージョンを確認してください。
テーマ・ロケール・認証情報など更新頻度が低いグローバル設定には Context API が適しています。カートや複雑な UI 状態のように、頻繁に更新されたり複数コンポーネントで参照したりするデータには、再レンダリング最適化が優れた Zustand を選ぶのが無難です。
本記事では、Next.js のサーバーコンポーネントにおける状態管理について、基本から応用まで解説しました。
サーバーコンポーネントとクライアントコンポーネントを適切に使い分けることで、ユーザー体験とパフォーマンスを両立した、プロフェッショナルな Next.js アプリケーション開発が可能になります。


副業・フリーランスが主流になっている今こそ、自らのスキルで稼げる人材を目指してみませんか?
未経験でも心配することはありません。初級コースを受講される方の大多数はプログラミング未経験です。まずは無料カウンセリングで、悩みや不安をお聞かせください!
公式サイト より
今すぐ
無料カウンセリング
を予約!