Next.js: データフェッチ: fetch と RSC
最終更新:2026-08-26
RSC では、
fetchはもはや単なるブラウザのフェッチではありません。キャッシュレイヤーを拡張し、データライフサイクルを宣言的な方法で制御できるようにします。
1. 学ぶこと
- サーバーコンポーネントにおける
fetch(url, options)の自動キャッシュ動作 - 3つのキャッシュモード:
force-cache、no-store、revalidate next: { tags }とrevalidateTag()によるオンデマンド再検証- 並列データ取得
Promise.all()によるウォーターフォール効果の回避 - 直列ウォーターフォールフローの特定と最適化
2. フルスタック開発者の実話
(1) ペインポイント: ダッシュボードの読み込みに8秒かかる
Bob は TaskFlow チームのテクニカルリードです。Dashboard ページは5つのデータソースを読み込む必要があります: ユーザー統計、プロジェクト総数、最近のタスク、アクティビティログ、システム通知です。初期のコードは5つの直列 await fetch(...) 呼び出しを使用し、それぞれが前の完了を待つため、合計時間は 2.1s + 1.8s + 1.5s + 0.9s + 1.7s = 8秒になりました。ユーザーはページの読み込みに「時間がかかりすぎる」と不満を言いました。さらに悪いことに、API はリフレッシュのたびに再クエリされ、データベース負荷が 5,000 QPS に急増しました。
(2) Next.js fetch の解決策
Promise.all()で並列リクエスト +next: { revalidate: 60 }で60秒キャッシュを使用します。
// app/dashboard/page.tsx
export default async function DashboardPage() {
const [users, projects, tasks, logs, notifs] = await Promise.all([
fetch('https://api.example.com/stats/users', { next: { revalidate: 60 } }),
fetch('https://api.example.com/stats/projects', { next: { revalidate: 60 } }),
fetch('https://api.example.com/stats/tasks', { next: { revalidate: 30 } }),
fetch('https://api.example.com/activity/logs', { cache: 'no-store' }),
fetch('https://api.example.com/notifications', { next: { revalidate: 10 } }),
]).then(responses => Promise.all(responses.map(r => r.json())))
return <DashboardView {...{ users, projects, tasks, logs, notifs }} />
}
(3) 効果
| 次元 | 最適化前 | 最適化後 |
|---|---|---|
| ページ読み込み時間 | 8秒 (直列) | 2.1秒 (並列) |
| データベース QPS | 5,000 | 83 (60秒キャッシュ) |
| ユーザー不満 | 1日12件 | 0 |
| コード行数 | 35行 (5つの個別フェッチ) | 10行 |
3. fetch の3つのキャッシュモード
Next.js 16 は Web fetch API を拡張し、3つのキャッシュモードを追加しています。RSC 内のすべての fetch は、別のモードが明示的に指定されない限り、デフォルトで force-cache (自動キャッシュ) を使用します。
graph LR
A[RSC fetch] --> B{キャッシュモード}
B --> C[force-cache<br/>デフォルト値]
B --> D[no-store<br/>毎回リフレッシュ]
B --> E[revalidate:N<br/>時間枠]
C --> F[データキャッシュ<br/>永続ストレージ]
D --> G[リアルタイムデータ<br/>キャッシュしない]
E --> H[N 秒間キャッシュ<br/>期限切れ後に再取得]
style C fill:#d4edda
style D fill:#f8d7da
style E fill:#fff3cd
| パターン | 構文 | 動作 | ユースケース |
|---|---|---|---|
force-cache (デフォルト) |
fetch(url) または fetch(url, { cache: 'force-cache' }) |
ビルド時または最初のリクエスト時にのみ取得。結果は永続的にキャッシュ | ほとんど変更されないデータ (ドキュメント、静的設定) |
no-store |
fetch(url, { cache: 'no-store' }) |
リクエストごとに新たにデータを取得。キャッシュなし | リアルタイムデータ (ユーザー情報、在庫) |
revalidate:N |
fetch(url, { next: { revalidate: 60 } }) |
60秒間キャッシュ。期限切れ時にバックグラウンドプロセスが更新をトリガー | 準リアルタイムデータ (ニュース、ランキング) |
(1) force-cache のデフォルト動作
オプションが渡されない場合、Next.js は fetch の結果を自動的にキャッシュします。同じ URL とオプションのリクエストはビルドプロセス中に1回だけ行われます。
// app/products/page.tsx — force-cache デフォルト
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products').then(r => r.json())
// ビルド時に1回取得、以降はキャッシュを使用
return <ProductList data={products} />
}
(2) no-store 動的データ
// app/profile/page.tsx — リクエストごとに最新データを取得
export default async function ProfilePage() {
const user = await fetch('https://api.example.com/me', {
cache: 'no-store',
headers: { Authorization: `Bearer ${process.env.API_TOKEN}` }
}).then(r => r.json())
return <ProfileView user={user} />
}
(3) revalidate 時間枠
// app/blog/[slug]/page.tsx — ISR スタイルキャッシュ
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { revalidate: 3600 } // 1時間キャッシュを使用
}).then(r => r.json())
return <article><h1>{post.title}</h1><div>{post.content}</div></article>
}
▶ サンプル: 3つのキャッシュモードの比較 (難易度: ⭐)
Fetches data and renders the result.
// app/cache-demo/page.tsx
export default async function CacheDemoPage() {
const staticData = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
cache: 'force-cache'
}).then(r => r.json())
const liveData = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
cache: 'no-store'
}).then(r => r.json())
return (
<div>
<p>Static (force-cache): {staticData.datetime}</p>
<p>Live (no-store): {liveData.datetime}</p>
</div>
)
}
Static (force-cache): 2026-07-06T10:00:00.000Z ← 常に同じ
Live (no-store): 2026-07-06T10:00:05.123Z ← リフレッシュするたびに変わる
ブラウザに2つのタイムスタンプがレンダリング:
Static (force-cache): 2026-07-06T10:00:00.000Z ← 常に同じ (ビルド時にキャッシュ)
Live (no-store): 2026-07-06T10:00:05.123Z ← リフレッシュごとに変わる
4. オンデマンド再検証: tags と revalidateTag
next: { tags: [...] } で fetch リクエストにタグを付け、サーバーアクションまたはルートハンドラ内で revalidateTag(tag) を使用して必要に応じてキャッシュをリフレッシュします。
sequenceDiagram
participant A as サーバーアクション
participant Cache as データキャッシュ
participant DB as データベース
A->>DB: 新規データを書き込み (タスク作成)
A->>Cache: revalidateTag('tasks')
Cache->>Cache: tags に一致する全キャッシュをクリア
Note over Cache: 次回の fetch で再取得
| API | 目的 | 呼び出し場所 |
|---|---|---|
next: { tags: ['tasks', 'projects'] } |
"fetch" にタグ付け | fetch() オプション |
revalidateTag('tasks') |
タグで関連キャッシュをすべてクリア | サーバーアクション / ルートハンドラ |
revalidatePath('/dashboard') |
パスでキャッシュをクリア | サーバーアクション / ルートハンドラ |
▶ サンプル: tags と revalidateTag の使用 (難易度: ⭐⭐)
// app/tasks/data.ts — データ取得関数
export async function getTasks() {
return fetch('https://api.example.com/tasks', {
next: { tags: ['tasks'] }
}).then(r => r.json())
}
// app/tasks/actions.ts — サーバーアクション 書き込み後にキャッシュをリフレッシュ
'use server'
import { revalidateTag } from 'next/cache'
export async function createTask(formData: FormData) {
const title = formData.get('title') as string
await fetch('https://api.example.com/tasks', {
method: 'POST',
body: JSON.stringify({ title, status: 'todo' })
})
revalidateTag('tasks') // tasks タグの全キャッシュエントリをクリア
}
Fetches data and renders the result.
▶ サンプル: revalidatePath でページ全体をクリア (難易度: ⭐⭐)
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function publishArticle() {
await db.article.update({ where: { id: 1 }, data: { published: true } })
revalidatePath('/blog') // /blog ページをリフレッシュ
revalidatePath('/blog/[slug]') // 全記事詳細をリフレッシュ
}
Renders the publishArticle component UI.
5. 並列データ取得とウォーターフォール効果の回避
ウォーターフォールパターンは最大のパフォーマンスキラーです。各 await が前の完了を順番に待ちます。Promise.all() を使用すると、すべてのリクエストを同時に開始できます。
graph LR
subgraph "ウォーターフォールパターン (遅い)"
A1[fetch A] --> A2[fetch B] --> A3[fetch C]
A1 -.- t1[2s]
A2 -.- t2[+2s = 4s]
A3 -.- t3[+2s = 6s]
end
subgraph "並列 (速い)"
B1[fetch A] -.- u1[2s]
C1[fetch B] -.- u2[2s]
D1[fetch C] -.- u3[2s]
B1 & C1 & D1 --> M[Promise.all<br/>合計時間 ~2s]
end
| パターン | 実装 | 合計時間 (各2秒) | 適用シナリオ |
|---|---|---|---|
| 直列ウォーターフォール | await A; await B; await C |
~6s | 依存リクエスト |
| 並列リクエスト | Promise.all([A, B, C]) |
~2s | 独立した無関係なリクエスト |
| 段階的並列処理 | const a = await A; const [b, c] = await Promise.all([B(a.id), C]) |
~4s | 部分的に依存するリクエスト |
▶ サンプル: 直列ウォーターフォールパターンの識別 (難易度: ⭐)
// app/waterfall/page.tsx — ❌ 直列ウォーターフォール
export default async function WaterfallPage() {
const user = await fetch('https://api.example.com/user').then(r => r.json()) // 1s
const tasks = await fetch(`https://api.example.com/tasks?userId=${user.id}`).then(r => r.json()) // 完了待ち + 2s = 3s
const details = await Promise.all(tasks.map(t =>
fetch(`https://api.example.com/tasks/${t.id}/details`).then(r => r.json()) // 完了待ち + 2s = 5s
))
return <div>Total: ~5s</div>
}
Fetches data and renders a list of items.
Visible text: Total: ~5s
▶ サンプル: 並列最適化 (難易度: ⭐⭐)
The page renders as described above, with the UI updating based on the described behavior.
// app/no-waterfall/page.tsx — ✅ 並列最適化
export default async function NoWaterfallPage() {
// ステージ1: ユーザーと初期データを並行取得
const [user, initialData] = await Promise.all([
fetch('https://api.example.com/user', { next: { revalidate: 10 } }).then(r => r.json()),
fetch('https://api.example.com/initial', { cache: 'no-store' }).then(r => r.json()),
])
// ステージ2: user.id に依存するリクエスト (小さなウォーターフォールは残るが、これが最適)
const tasks = await fetch(`https://api.example.com/tasks?userId=${user.id}`).then(r => r.json())
return <div>Total: ~2s (1s + 1s parallel, then 1s)</div>
}
Fetches data and renders the result.
Visible text: Total: ~2s (1s + 1s parallel, then 1s)
▶ サンプル: Suspense 遅延ローディング (難易度: ⭐⭐⭐)
The page renders as described above, with the UI updating based on the described behavior.
// app/suspense-demo/page.tsx — 各領域を個別に Suspense でラップ
import { Suspense } from 'react'
export default function SuspenseDemoPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading profile...</div>}>
<ProfileSection />
</Suspense>
<Suspense fallback={<div>Loading tasks...</div>}>
<TaskSection />
</Suspense>
</div>
)
}
async function ProfileSection() {
const user = await fetch('https://api.example.com/user', { cache: 'no-store' }).then(r => r.json())
return <div>Welcome, {user.name}</div>
}
async function TaskSection() {
const tasks = await fetch('https://api.example.com/tasks', { next: { revalidate: 30 } }).then(r => r.json())
return <ul>{tasks.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>
}
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: Loading profile...
Visible text: Dashboard | Loading profile... | }> | Loading tasks...
6. 完全な例: 最適化されたダッシュボード
// app/dashboard-optimized/page.tsx
import { Suspense } from 'react'
import { revalidateTag } from 'next/cache'
// ======== データ関数 ========
const API = 'https://jsonplaceholder.typicode.com'
async function getData<T>(endpoint: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API}${endpoint}`, {
...options,
next: { tags: [endpoint.split('/')[1] ?? 'default'], ...(options as any)?.next },
})
if (!res.ok) throw new Error(`Failed to fetch ${endpoint}`)
return res.json()
}
// ======== 全データを並列取得 ========
export default function DashboardOptimizedPage() {
return (
<div>
<h1>Optimized Dashboard</h1>
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: '1fr 1fr' }}>
<Suspense fallback={<Skeleton label="Users" />}>
<DataCard title="Users" endpoint="/users" />
</Suspense>
<Suspense fallback={<Skeleton label="Posts" />}>
<DataCard title="Posts" endpoint="/posts" revalidate={120} />
</Suspense>
<Suspense fallback={<Skeleton label="Comments" />}>
<DataCard title="Comments" endpoint="/comments" />
</Suspense>
<Suspense fallback={<Skeleton label="Todos" />}>
<DataCard title="Todos" endpoint="/todos" revalidate={30} />
</Suspense>
</div>
</div>
)
}
async function DataCard({ title, endpoint, revalidate }: {
title: string
endpoint: string
revalidate?: number
}) {
const data = await getData<any[]>(endpoint, revalidate
? { next: { revalidate } }
: { cache: 'no-store' }
)
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<h2>{title} <span style={{ fontSize: 14, color: '#666' }}>({data.length})</span></h2>
<ul>{data.slice(0, 5).map((item: any) => (
<li key={item.id}>{item.title ?? item.name ?? item.email}</li>
))}</ul>
</div>
)
}
function Skeleton({ label }: { label: string }) {
return <div style={{ border: '1px solid #eee', borderRadius: 8, padding: 16, opacity: 0.5 }}>
Loading {label}...
</div>
}
// app/dashboard-optimized/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function refreshSection(tag: string) {
revalidateTag(tag)
return { success: true }
}
❓ よくある質問
force-cache と no-store オプションは開発モードと本番モードで同じ動作をしますか?npm run dev) では、force-cache もリクエストごとにフェッチされます (デバッグを容易にするため)。キャッシュは本番モード (next start またはビルド後) でのみ有効になります。これは Next.js の設計上の決定で、開発段階では常に最新データをフェッチするようになっています。revalidateTag と revalidatePath の違いは何ですか?revalidateTag はタグでキャッシュをクリアし (異なるページの同じデータ向け)、revalidatePath はパスでキャッシュをクリアします (ページまたはルートパターン単位)。前者はきめ細かいデータレイヤー制御に適し、後者はページレベルのリフレッシュに適しています。可能な限り revalidateTag の使用を推奨します。cache や next.revalidate 設定が異なるリクエストは、異なるキャッシュエントリとして扱われます。Promise.all でリクエストが失敗した場合、どう処理されますか?Promise.all は「オールオアナッシング」操作です。1つのリクエストが失敗すると、Promise 全体が reject されます。エラーを処理する必要がある場合は、各 fetch 呼び出しを Promise.allSettled または try-catch ブロックでラップします。一般的なパターンは const results = await Promise.all(urls.map(u => fetch(u).catch(() => null))) です。fetch のタイムアウトはどう処理されますか?fetch には組み込みのタイムアウトがありません。AbortController でラップできます: const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 5000); fetch(url, { signal: ctrl.signal })。アプリレベルで統一された fetch クライアントをカプセル化することを推奨します。fetch の拡張機能 (自動キャッシュ、tags、再検証など) が失われます。axios を使用する場合は、キャッシュロジックを手動で実装するか、axios の周りに fetch 互換レイヤーをラップする必要があります。可能な限りネイティブの fetch メソッドを使用することを推奨します。📖 まとめ
- RSC の
fetchメソッドはデフォルトでforce-cacheを使用し、同一 URL は自動的にキャッシュされます cache: 'no-store'はキャッシュを無効にし、リアルタイムデータに適していますnext: { revalidate: N }は時間枠キャッシュを実装します (ISR に類似)next: { tags: [...] }+revalidateTag()でオンデマンドキャッシュリフレッシュを実装しますPromise.all()並列リクエストは「ウォーターフォール効果」を防ぎ、読み込み時間を 60〜80% 削減できます- Suspense は境界分割でストリーミングローディングを有効にし、全データの準備を待つ必要がありません
revalidatePath()はパスでページレベルのキャッシュをクリアします
📝 練習問題
-
基礎問題 (⭐):
app/time-demo/page.tsxを作成し、cache: 'no-store'とcache: 'force-cache'を使用して World Time API に別々にリクエストを行い、2つのタイムスタンプの違いを比較し、キャッシュ動作を検証してください。 -
発展問題 (⭐⭐):
app/parallel-demo/page.tsxを構築し、Promise.allを使用して/users、/posts、/comments(JSONPlaceholder API を使用) をフェッチし、各データポイントを個別の<Suspense>バウンダリ内でレンダリングして、ストリーミングローディング効果をデモしてください。 -
チャレンジ (⭐⭐⭐): CRUD 操作をサポートするタスク一覧ページを作成してください:
app/tasks/page.tsx(タスク一覧を表示、キャッシュに tags を使用) とapp/tasks/actions.tsx(タスクの追加または削除後にrevalidateTag('tasks')を呼び出して一覧をリフレッシュ)。楽観的更新を実装し、書き込み操作後に一覧が即座にリフレッシュされることを確認してください。