Next.js: データフェッチ: fetch と RSC

最終更新:2026-08-26

RSC では、fetch はもはや単なるブラウザのフェッチではありません。キャッシュレイヤーを拡張し、データライフサイクルを宣言的な方法で制御できるようにします。

1. 学ぶこと



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秒キャッシュを使用します。

TSX
// 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 (自動キャッシュ) を使用します。

100%
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回だけ行われます。

TSX
// 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 動的データ

TSX
// 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 時間枠

TSX
// 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つのキャッシュモードの比較 (難易度: ⭐)

💻 出力:

TEXT 📖 参照専用
Fetches data and renders the result.
TSX
// 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>
  )
}
💻 出力:

TEXT 📖 参照専用
Static (force-cache): 2026-07-06T10:00:00.000Z  ← 常に同じ
Live (no-store): 2026-07-06T10:00:05.123Z       ← リフレッシュするたびに変わる
💻 出力:

TEXT 📖 参照専用
ブラウザに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) を使用して必要に応じてキャッシュをリフレッシュします。

100%
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') パスでキャッシュをクリア サーバーアクション / ルートハンドラ

▶ サンプル: tagsrevalidateTag の使用 (難易度: ⭐⭐)

TSX
// app/tasks/data.ts — データ取得関数
export async function getTasks() {
  return fetch('https://api.example.com/tasks', {
    next: { tags: ['tasks'] }
  }).then(r => r.json())
}
TSX
// 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 タグの全キャッシュエントリをクリア
}
💻 出力:

TEXT 📖 参照専用
Fetches data and renders the result.

▶ サンプル: revalidatePath でページ全体をクリア (難易度: ⭐⭐)

TSX
// 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]') // 全記事詳細をリフレッシュ
}
💻 出力:

TEXT 📖 参照専用
Renders the publishArticle component UI.


5. 並列データ取得とウォーターフォール効果の回避

ウォーターフォールパターンは最大のパフォーマンスキラーです。各 await が前の完了を順番に待ちます。Promise.all() を使用すると、すべてのリクエストを同時に開始できます。

100%
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 部分的に依存するリクエスト

▶ サンプル: 直列ウォーターフォールパターンの識別 (難易度: ⭐)

TSX
// 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>
}
💻 出力:

TEXT 📖 参照専用
Fetches data and renders a list of items.
Visible text: Total: ~5s

▶ サンプル: 並列最適化 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
The page renders as described above, with the UI updating based on the described behavior.
TSX
// 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>
}
💻 出力:

TEXT 📖 参照専用
Fetches data and renders the result.
Visible text: Total: ~2s (1s + 1s parallel, then 1s)

▶ サンプル: Suspense 遅延ローディング (難易度: ⭐⭐⭐)

💻 出力:

TEXT 📖 参照専用
The page renders as described above, with the UI updating based on the described behavior.
TSX
// 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>
}
💻 出力:

TEXT 📖 参照専用
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: Loading profile...
Visible text: Dashboard | Loading profile... | }> | Loading tasks...


6. 完全な例: 最適化されたダッシュボード

TSX
// 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 }
}

❓ よくある質問

Q fetch の force-cacheno-store オプションは開発モードと本番モードで同じ動作をしますか?
A いいえ、異なります。開発モード (npm run dev) では、force-cache もリクエストごとにフェッチされます (デバッグを容易にするため)。キャッシュは本番モード (next start またはビルド後) でのみ有効になります。これは Next.js の設計上の決定で、開発段階では常に最新データをフェッチするようになっています。
Q revalidateTagrevalidatePath の違いは何ですか?
A revalidateTag はタグでキャッシュをクリアし (異なるページの同じデータ向け)、revalidatePath はパスでキャッシュをクリアします (ページまたはルートパターン単位)。前者はきめ細かいデータレイヤー制御に適し、後者はページレベルのリフレッシュに適しています。可能な限り revalidateTag の使用を推奨します。
Q 2つの fetch リクエストが同じ URL で異なるオプションを使用する場合、キャッシュを共有しますか?
A いいえ。キャッシュキーは URL、メソッド、ヘッダー、ボディに基づいて計算されます。同じ URL でも cachenext.revalidate 設定が異なるリクエストは、異なるキャッシュエントリとして扱われます。
Q Promise.all でリクエストが失敗した場合、どう処理されますか?
A Promise.all は「オールオアナッシング」操作です。1つのリクエストが失敗すると、Promise 全体が reject されます。エラーを処理する必要がある場合は、各 fetch 呼び出しを Promise.allSettled または try-catch ブロックでラップします。一般的なパターンは const results = await Promise.all(urls.map(u => fetch(u).catch(() => null))) です。
Q RSC における fetch のタイムアウトはどう処理されますか?
A fetch には組み込みのタイムアウトがありません。AbortController でラップできます: const ctrl = new AbortController(); setTimeout(() => ctrl.abort(), 5000); fetch(url, { signal: ctrl.signal })。アプリレベルで統一された fetch クライアントをカプセル化することを推奨します。
Q RSC でサードパーティの HTTP クライアント (axios など) を使用できますか?
A はい、ただし Next.js の fetch の拡張機能 (自動キャッシュ、tags、再検証など) が失われます。axios を使用する場合は、キャッシュロジックを手動で実装するか、axios の周りに fetch 互換レイヤーをラップする必要があります。可能な限りネイティブの fetch メソッドを使用することを推奨します。

📖 まとめ


📝 練習問題

  1. 基礎問題 (⭐): app/time-demo/page.tsx を作成し、cache: 'no-store'cache: 'force-cache' を使用して World Time API に別々にリクエストを行い、2つのタイムスタンプの違いを比較し、キャッシュ動作を検証してください。

  2. 発展問題 (⭐⭐): app/parallel-demo/page.tsx を構築し、Promise.all を使用して /users/posts/comments (JSONPlaceholder API を使用) をフェッチし、各データポイントを個別の <Suspense> バウンダリ内でレンダリングして、ストリーミングローディング効果をデモしてください。

  3. チャレンジ (⭐⭐⭐): CRUD 操作をサポートするタスク一覧ページを作成してください: app/tasks/page.tsx (タスク一覧を表示、キャッシュに tags を使用) と app/tasks/actions.tsx (タスクの追加または削除後に revalidateTag('tasks') を呼び出して一覧をリフレッシュ)。楽観的更新を実装し、書き込み操作後に一覧が即座にリフレッシュされることを確認してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%