Next.js: キャッシュコンポーネント & use cache

最終更新:2026-08-26

use cache は Next.js 16 で最も革新的な API です — キャッシュを「データ取得の副作用」から「コンポーネントレベルのファーストクラスシチズン」に引き上げます。

1. 学習目標



2. あるシステムアーキテクトの実話

(1) 課題: 同じページで4回の同一 API 呼び出し

TaskFlow ダッシュボードをレビュー中、Charlie はページ上の4つのコンポーネント — <UserAvatar><UserGreeting><UserStats><UserNotifications> — がそれぞれ fetch('/api/user') を呼び出していることに気づきました。同じ URL に対するキャッシュヒットは正常に動作していましたが、データベースクエリ関数 getUserFromDB() は4回呼び出されていました。fetch キャッシュは HTTP リクエストにのみ適用され、サーバー内部の関数呼び出しに対してはまったく効果がありません。

問題 データ
ページレベルのデータベース呼び出し 4回の同一クエリ
1クエリあたりの時間 200 ms
追加の合計時間 600 ms 無駄
データベース QPS の無駄 4倍

(2) use cache による解決策

関数を use cache() でラップします — これにより、データベースクエリ、計算、ファイル読み取りを含む、あらゆる内部関数呼び出しがキャッシュされます。

TSX
// app/dashboard/page.tsx
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

async function getUserData() {
  'use cache'
  cacheTag('user-data')
  cacheLife({ stale: 300, revalidate: 600 })

  const user = await db.user.findUnique({ where: { id: 1 } })  // 1回だけ実行
  return user
}

export default async function DashboardPage() {
  const user = await getUserData()  // 最初の呼び出しでデータベースクエリを実行
  // 後続の呼び出しはキャッシュされた結果を直接返します

  return (
    <div>
      <UserAvatar user={user} />
      <UserGreeting user={user} />
      <UserStats user={user} />
      <UserNotifications user={user} />
    </div>
  )
}

(3) 成果

指標 従来モデル use cache
データベースクエリ数 4 1
追加遅延 600 ms 0 ms
キャッシュ粒度 URL レベル 関数/コンポーネントレベル
カスタムロジックキャッシュ ❌ 非対応 (HTTP fetch のみ) ✅ 任意のコードに対応


3. use cache() ディレクティブとコンテンツキャッシュ

use cache関数レベルのディレクティブです — 関数の先頭に 'use cache' を追加すると、その関数の結果がキャッシュされることを示します。キャッシュされた内容は コンテンツキャッシュ と呼ばれ、Next.js 16 で導入されたまったく新しいキャッシュレイヤーで、従来のデータキャッシュ (fetch キャッシュ) とは独立しています。

100%
graph TB
    subgraph "Next.js 16 キャッシュシステム"
        A[リクエストメモ化<br/>リクエストレベルのメモリ]
        B[データキャッシュ<br/>fetch キャッシュ]
        C[コンテンツキャッシュ<br/>use cache]
        D[フルルートキャッシュ<br/>全ルートキャッシュ]
    end

    A --> E[同一リクエスト内<br/>同じ fetch の重複排除]
    B --> F[クロスリクエスト永続化<br/>force-cache/no-store]
    C --> G[任意の関数結果のキャッシュ<br/>tag + life 制御]
    D --> H[ページレベル HTML キャッシュ]

    style C fill:#d4edda
キャッシュレベル スコープ トリガー ライフサイクル
リクエストメモ化 単一リクエスト 自動 (同一 URL) リクエスト終了
データキャッシュ クロスリクエスト fetch(options) 設定依存
コンテンツキャッシュ クロスリクエスト 'use cache' ディレクティブ cacheLife + cacheTag
フルルートキャッシュ クロスリクエスト ビルド時 / ランタイム Revalidate / オンデマンド

(1) 基本構文

TSX
// app/cache-demo/actions.ts
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

export async function getExpensiveData(id: string) {
  'use cache'
  cacheTag('expensive', `id-${id}`)         // タグ付け
  cacheLife({ stale: 60, revalidate: 300 }) // 60秒以内は stale を返す、300秒で再検証

  // この関数内のすべての操作がキャッシュされます
  const result = await db.query(...)
  return result
}

(2) cacheLife 設定

パラメータ 説明
stale number (秒) キャッシュ有効期間中は結果を直接返す。バックグラウンド更新はトリガーしない { stale: 60 }
revalidate number (秒) この時間経過後に関数を再実行する { revalidate: 3600 }
expire number (秒) キャッシュが完全に期限切れ。強制再取得 { expire: 86400 }

▶ サンプル: 基本的な use cache の使用 (難易度 ⭐)

💻 出力:

TEXT 📖 参照専用
getExpensiveData コンポーネントの UI をレンダリングします。
TSX
// app/cache-basic/page.tsx
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

async function getServerTime() {
  'use cache'
  cacheTag('server-time')
  cacheLife({ stale: 10, revalidate: 30 })

  // 時間のかかる操作をシミュレート
  await new Promise(resolve => setTimeout(resolve, 1000))
  return { time: new Date().toISOString(), server: process.env.HOSTNAME ?? 'local' }
}

export default async function CacheBasicPage() {
  const [t1, t2, t3] = await Promise.all([
    getServerTime(),
    getServerTime(),
    getServerTime(),
  ])

  return (
    <div>
      <h1>use cache — 基本</h1>
      <p>呼び出し1: {t1.time}</p>
      <p>呼び出し2: {t2.time}</p>
      <p>呼び出し3: {t3.time}</p>
      <p><em>3つの呼び出しすべてが同じキャッシュ結果を返しました (重複計算なし)</em></p>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
呼び出し1: 2026-07-06T10:00:00.000Z
呼び出し2: 2026-07-06T10:00:00.000Z ← 同じタイムスタンプ (キャッシュヒット)
呼び出し3: 2026-07-06T10:00:00.000Z ← 同じタイムスタンプ (キャッシュヒット)
💻 出力:

TEXT 📖 参照専用
呼び出し1: 2026-07-06T10:00:00.000Z
呼び出し2: 2026-07-06T10:00:00.000Z  ← キャッシュのミリ秒が重複
呼び出し3: 2026-07-06T10:00:00.000Z


4. cacheTag と cacheLife の実践的使用

cacheTag() はキャッシュコンテンツにラベルを付け、cacheLife() は有効期限ポリシーを制御します。revalidateTag() と組み合わせることで、きめ細かいキャッシュ制御が可能になります。

(1) タグ付けと分類戦略

100%
graph TB
    A[アプリケーションデータ] --> B[ユーザーデータ]
    A --> C[商品データ]
    A --> D[注文データ]
    B --> E[tag: user-profile]
    B --> F[tag: user-settings]
    C --> G[tag: products]
    C --> H[tag: product-{id}]
    D --> I[tag: orders]
    D --> J[tag: order-{id}]

    style A fill:#cce5ff
戦略 タグの例 無効化操作 スコープ
きめ細かい product-42 revalidateTag('product-42') 単一商品のみ
中粒度 products revalidateTag('products') 全商品
粗粒度 catalog revalidateTag('catalog') ディレクトリ全体

(2) 動的タグ

TSX
// app/products/[id]/page.tsx
import { unstable_cacheTag as cacheTag } from 'next/cache'

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id)
  return <ProductView product={product} />
}

async function getProduct(id: string) {
  'use cache'
  cacheTag('products', `product-${id}`)  // パラメータに基づく動的タグ
  return fetch(`https://api.example.com/products/${id}`).then(r => r.json())
}

▶ サンプル: 階層化キャッシュ戦略 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
データを取得し、結果をレンダリングします。
TSX
// app/cache-strategy/page.tsx
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

async function getUserProfile(userId: number) {
  'use cache'
  cacheTag('users', `user-${userId}`)
  cacheLife({ stale: 120, revalidate: 600 })  // 2分 stale、10分 revalidate
  return fetch(`https://jsonplaceholder.typicode.com/users/${userId}`).then(r => r.json())
}

async function getUserPosts(userId: number) {
  'use cache'
  cacheTag('posts', `user-posts-${userId}`)
  cacheLife({ stale: 60, revalidate: 300 })
  return fetch(`https://jsonplaceholder.typicode.com/users/${userId}/posts`).then(r => r.json())
}

export default async function CacheStrategyPage() {
  const [profile, posts] = await Promise.all([
    getUserProfile(1),
    getUserPosts(1),
  ])

  return (
    <div>
      <h1>{profile.name}</h1>
      <p>Email: {profile.email}</p>
      <h2>Posts ({posts.length})</h2>
      <ul>{posts.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
データをサーバーサイドで取得し、データからアイテムのリストをレンダリングします。

▶ サンプル: Server Action でタグによる無効化 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
コンポーネントがブラウザで説明された UI をレンダリングします。
TSX
// app/cache-invalidation/page.tsx
import { unstable_cacheTag as cacheTag, revalidateTag } from 'next/cache'

async function getTaskList() {
  'use cache'
  cacheTag('tasks')
  return fetch('https://jsonplaceholder.typicode.com/todos?_limit=5').then(r => r.json())
}

export default async function CacheInvalidationPage() {
  const tasks = await getTaskList()
  return (
    <div>
      <h1>タスク一覧</h1>
      <ul>{tasks.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>
      <form action={async () => {
        'use server'
        revalidateTag('tasks')  // ボタンクリックでタスク一覧のキャッシュを即時更新
      }}>
        <button type="submit">タスクを更新</button>
      </form>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
入力フィールドと送信ボタンを持つフォーム。
送信時、サーバーアクションがデータを処理し、関連するキャッシュタグを無効化します。
表示テキスト: Task List


5. 暗黙的な fetch キャッシュ vs 明示的な use cache キャッシュ

指標 暗黙的 fetch キャッシュ (データキャッシュ) 明示的 use cache (コンテンツキャッシュ)
トリガー方法 fetch(url) 自動 関数本体の 'use cache' ディレクティブ
キャッシュ内容 HTTP レスポンス 任意の関数が返す結果
ユースケース API 呼び出し、HTTP リクエスト データベースクエリ、複雑な計算、ファイル読み取り
タグシステム next: { tags: [...] } cacheTag() 関数
時間制御 next: { revalidate: N } cacheLife() 関数
無効化方法 revalidateTag() / revalidatePath() revalidateTag() (同じラベル)

(1) いつ fetch キャッシュを使い、いつ use cache を使うべきか?

シナリオ 推奨キャッシュ方法 理由
外部 API の呼び出し データキャッシュ (fetch) HTTP セマンティクスのネイティブ対応
データベース呼び出し コンテンツキャッシュ (use cache) データベース呼び出しは HTTP fetch ではない
複雑な計算 (50 ms+) コンテンツキャッシュ 任意の関数をキャッシュ可能
ファイル/設定の読み取り コンテンツキャッシュ ファイル操作は fetch で実行不可
HTTP + DB 混合処理 コンテンツキャッシュ ロジック全体のブロックをキャッシュ

▶ サンプル: 2つのキャッシュの組み合わせ (難易度: ⭐⭐⭐)

💻 出力:

TEXT 📖 参照専用
上記の説明通りにページをレンダリングし、説明の動作に基づいて UI が更新されます。
TSX
// app/cache-combo/page.tsx
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

// 1. fetch キャッシュ: 外部 API を呼び出し
async function getExternalPosts() {
  return fetch('https://jsonplaceholder.typicode.com/posts', {
    next: { tags: ['external-posts'], revalidate: 300 }
  }).then(r => r.json())
}

// 2. use cache: 処理済みデータ
async function getProcessedPosts() {
  'use cache'
  cacheTag('processed-posts')
  cacheLife({ stale: 60, revalidate: 600 })

  const raw = await getExternalPosts()  // fetch キャッシュに依存
  return (raw as any[]).map((p: any) => ({
    id: p.id,
    title: p.title.toUpperCase(),
    summary: p.body.slice(0, 100),
  }))
}

export default async function CacheComboPage() {
  const posts = await getProcessedPosts()

  return (
    <div>
      <h1>Processed Posts ({posts.length})</h1>
      <ul>{posts.map((p: any) => (
        <li key={p.id}><strong>{p.title}</strong><p>{p.summary}</p></li>
      ))}</ul>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
データをサーバーサイドで取得し、データからアイテムのリストをレンダリングします。


6. 完全な例: ユーザー管理システムのキャッシュアーキテクチャ

TSX
// app/cache-system/page.tsx — 完全なキャッシュアーキテクチャ
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

// ======== データレイヤー (use cache 明示的キャッシュ) ========
type User = { id: number; name: string; email: string }
type Post = { id: number; title: string; body: string }

async function getUsers(): Promise<User[]> {
  'use cache'
  cacheTag('users')
  cacheLife({ stale: 120, revalidate: 600 })
  return fetch('https://jsonplaceholder.typicode.com/users').then(r => r.json())
}

async function getUserPosts(userId: number): Promise<Post[]> {
  'use cache'
  cacheTag('posts', `user-posts-${userId}`)
  cacheLife({ stale: 60, revalidate: 300 })
  return fetch(`https://jsonplaceholder.typicode.com/users/${userId}/posts`).then(r => r.json())
}

async function getStats(users: User[]) {
  'use cache'
  cacheTag('stats')
  cacheLife({ stale: 300, revalidate: 1800 })
  return {
    totalUsers: users.length,
    avgNameLength: users.reduce((s, u) => s + u.name.length, 0) / users.length,
  }
}

// ======== ページコンポーネント ========
export default async function CacheSystemPage() {
  const users = await getUsers()
  const stats = await getStats(users)

  return (
    <div style={{ maxWidth: 900, margin: '0 auto', padding: 24 }}>
      <h1>ユーザー管理</h1>
      <StatsCard stats={stats} />
      <div style={{ display: 'grid', gap: 16, marginTop: 24 }}>
        {users.map(user => (
          <UserCard key={user.id} user={user} />
        ))}
      </div>
    </div>
  )
}

// ======== 子コンポーネント (独立キャッシュ) ========
async function UserCard({ user }: { user: User }) {
  const posts = await getUserPosts(user.id)
  return (
    <div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
      <h2>{user.name}</h2>
      <p style={{ color: '#666' }}>{user.email}</p>
      <details>
        <summary>Posts ({posts.length})</summary>
        <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
      </details>
    </div>
  )
}

function StatsCard({ stats }: { stats: { totalUsers: number; avgNameLength: number } }) {
  return (
    <div style={{ background: '#f0f4ff', borderRadius: 8, padding: 16, display: 'flex', gap: 32 }}>
      <div><strong>合計ユーザー数</strong><p style={{ fontSize: 24 }}>{stats.totalUsers}</p></div>
      <div><strong>平均名前長</strong><p style={{ fontSize: 24 }}>{stats.avgNameLength.toFixed(1)}</p></div>
    </div>
  )
}

// ======== 管理タスク ========
// app/cache-system/actions.ts
'use server'
import { revalidateTag } from 'next/cache'

export async function refreshUserData() {
  revalidateTag('users')        // ユーザー一覧を更新
}

export async function refreshPosts(userId: number) {
  revalidateTag(`user-posts-${userId}`)  // 特定ユーザーの投稿を更新
}

export async function refreshAll() {
  revalidateTag('users')
  revalidateTag('posts')
  revalidateTag('stats')
}

// app/cache-system/admin-button.tsx
'use client'
export function AdminControls() {
  return (
    <div style={{ display: 'flex', gap: 8, margin: '16px 0' }}>
      <form action={async () => {
        const { refreshUserData } = await import('./actions')
        await refreshUserData()
      }}>
        <button type="submit">ユーザーを更新</button>
      </form>
      <form action={async () => {
        const { refreshAll } = await import('./actions')
        await refreshAll()
      }}>
        <button type="submit">すべて更新</button>
      </form>
    </div>
  )
}

▶ サンプル: キャッシュヒットの検証 (難易度: ⭐⭐)

💻 出力:

TEXT 📖 参照専用
入力フィールドと送信ボタンを持つフォーム。
送信時、サーバーアクションがデータを処理し、関連するキャッシュタグを無効化します。
表示テキスト: User Management
TSX
// app/cache-verify/page.tsx — キャッシュヒットを確認
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'

let callCount = 0

async function getUniqueId() {
  'use cache'
  cacheTag('unique-id')
  cacheLife({ revalidate: 30 })
  callCount++
  return { id: crypto.randomUUID(), calls: callCount }
}

export default async function CacheVerifyPage() {
  const [a, b, c] = await Promise.all([
    getUniqueId(),
    getUniqueId(),
    getUniqueId(),
  ])

  return (
    <div>
      <h1>キャッシュ検証</h1>
      <p>結果 A: {a.id} (呼び出し #{a.calls})</p>
      <p>結果 B: {b.id} (呼び出し #{b.calls})</p>
      <p>結果 C: {c.id} (呼び出し #{c.calls})</p>
      <p><strong>同じ ID + calls = 1 → キャッシュヒット</strong></p>
    </div>
  )
}
💻 出力:

TEXT 📖 参照専用
内容: Cache Verification | Same ID + calls = 1 → キャッシュヒット
💻 出力:

TEXT 📖 参照専用
結果 A: 550e8400-e29b-41d4-a716-446655440000 (呼び出し #1)
結果 B: 550e8400-e29b-41d4-a716-446655440000 (呼び出し #1)  ← キャッシュヒット
結果 C: 550e8400-e29b-41d4-a716-446655440000 (呼び出し #1)  ← キャッシュヒット

❓ よくある質問

Q use cacheuseMemo の違いは何ですか?
A useMemo はクライアントサイドのフックで、ブラウザ内でのみ計算をキャッシュし、単一レンダリングのスコープです。use cache はサーバーサイドのディレクティブで、リクエスト間でキャッシュを永続化し、タグと有効期限をサポートし、Server Actions でオンデマンド無効化できます。2つはまったく異なるユースケースです。
Q cacheLife の「stale」と「revalidate」の違いは何ですか?
A stale 以内はキャッシュ結果が直接返されます (バックグラウンド更新はトリガーされません)。stale を超えても revalidate 未満の場合は、キャッシュ結果が返されますが、バックグラウンド再計算がトリガーされます。revalidate を超えると、新しい結果を待ちます。推奨設定: stale をユーザーが許容できる遅延に、revalidate をデータの最大鮮度に設定します。
Q 同じタグを fetchuse cache の両方に使えますか?
A はい。revalidateTag('products') は、fetch データキャッシュの next: { tags: ['products'] } ラベル付きエントリと、コンテンツキャッシュの cacheTag('products') ラベル付きエントリの両方をクリアします。これは統一キャッシュ無効化を実現するための重要なメカニズムです。
Q use cache はクライアントコンポーネントで使用できますか?
A いいえ。'use cache' ディレクティブはサーバーコンポーネントまたはサーバー関数でのみ有効です。クライアントコンポーネントでは、useMemo または React Query などのクライアントサイドキャッシュソリューションを使用する必要があります。
Q unstable_cacheLifeunstable_cacheTagunstable_ プレフィックスは何を意味しますか?
A API がまだ開発中であり、将来のバージョンで変更される可能性があることを意味します。Next.js 16.2 で利用可能ですが、公式アップデートに注意することを推奨します。unstable_ プレフィックスは通常 1〜2 のメジャーリリース後に削除されます (Next.js 17 または 18 の安定版で想定)。
Q コンテンツキャッシュのデータはどこに保存されますか?
A デフォルトでは、コンテンツキャッシュはメモリに保存されます (本番環境ではリクエスト間で永続化)。Vercel にデプロイする場合はエッジストレージを使用します。セルフホスト環境では、ファイルシステムまたはメモリに保存されます。キャッシュサイズはサーバーのメモリによって制限され、大規模なキャッシュはメモリリソースを消費します。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐): app/cache-demo/page.tsx を作成し、'use cache' 関数で模擬的な時間のかかる操作 (await new Promise(resolve => setTimeout(resolve, 2000))) をラップします。ページ上で3回呼び出し、2回目以降の呼び出しがゼロ遅延 (キャッシュヒット) で開始されることを確認します。

  2. 応用問題 (⭐⭐): ユーザー一覧とユーザー記事一覧を含むページを構築します。ユーザー一覧には cacheLife({ revalidate: 300 }) を、記事一覧には cacheLife({ revalidate: 60 }) を使用します。ページ下部に、ユーザータブと記事タブをそれぞれ更新する2つの Server Action ボタンを追加します。タブが独立して機能することを確認します。

  3. 発展問題 (⭐⭐⭐): 3つの異なるソース (HTTP API + 模擬データベース + 計算関数) からデータを取得し、use cache で集中キャッシュする「データ分析ダッシュボード」を実装します。各データソースに異なる cacheLife 戦略を設定します。revalidateTag を呼び出してすべてのダッシュボードを同時に更新する「強制更新」ボタンを追加します。キャッシュヒット率を追跡するツールを構築します。

Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%