Next.jsデータフェッチ: キャッシュ、再検証、レンダリング戦略

Shunku

Next.js App Routerは非同期Server Componentsを通じて強力なデータフェッチ機能を提供します。キャッシュと再検証を理解することが、パフォーマンスの高いアプリケーション構築の鍵です。

データフェッチの概要

flowchart TD
    A[リクエスト] --> B{キャッシュ済み?}
    B -->|はい| C[キャッシュを返す]
    B -->|いいえ| D[データをフェッチ]
    D --> E[レスポンスをキャッシュ]
    E --> F[データを返す]

    G[再検証] -->|時間ベース| H[N秒後]
    G -->|オンデマンド| I[revalidatePath/Tag]

    style C fill:#10b981,color:#fff
    style D fill:#3b82f6,color:#fff

Server Componentsでのデータフェッチ

基本的なフェッチ

// app/users/page.tsx
async function getUsers() {
  const res = await fetch('https://api.example.com/users');

  if (!res.ok) {
    throw new Error('ユーザーの取得に失敗しました');
  }

  return res.json();
}

export default async function UsersPage() {
  const users = await getUsers();

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

直接データベースアクセス

// app/products/page.tsx
import { db } from '@/lib/database';

export default async function ProductsPage() {
  // 直接データベースクエリ - APIは不要
  const products = await db.query('SELECT * FROM products');

  return (
    <div>
      {products.map((product) => (
        <div key={product.id}>
          <h2>{product.name}</h2>
          <p>¥{product.price}</p>
        </div>
      ))}
    </div>
  );
}

複数の並列フェッチ

// app/dashboard/page.tsx
async function getUser() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

async function getNotifications() {
  const res = await fetch('https://api.example.com/notifications');
  return res.json();
}

export default async function DashboardPage() {
  // 並列フェッチ - 順次より高速
  const [user, posts, notifications] = await Promise.all([
    getUser(),
    getPosts(),
    getNotifications(),
  ]);

  return (
    <div>
      <h1>ようこそ、{user.name}さん</h1>
      <p>{posts.length}件の投稿があります</p>
      <p>{notifications.length}件の通知</p>
    </div>
  );
}

キャッシュ戦略

デフォルトのキャッシュ動作

// デフォルトでキャッシュ(force-cacheと同等)
const res = await fetch('https://api.example.com/data');

// キャッシュ動作を明示的に設定
const res = await fetch('https://api.example.com/data', {
  cache: 'force-cache', // デフォルト - 無期限にキャッシュ
});

キャッシュなし

// キャッシュしない - 常に新鮮なデータをフェッチ
const res = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

時間ベースの再検証

// 60秒ごとに再検証
const res = await fetch('https://api.example.com/data', {
  next: { revalidate: 60 },
});

比較

戦略 使い方 ユースケース
force-cache 永久にキャッシュ 静的コンテンツ
no-store キャッシュしない リアルタイムデータ
revalidate: N N秒間キャッシュ 準動的データ

セグメントレベルのキャッシュ

動的ルートセグメント

// app/posts/[id]/page.tsx

// このルートを動的にする
export const dynamic = 'force-dynamic';

// または静的にする
export const dynamic = 'force-static';

// 1時間ごとに再検証
export const revalidate = 3600;

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const post = await getPost(id);

  return <article>{post.content}</article>;
}

ルートセグメント設定オプション

// 動的レンダリングを強制
export const dynamic = 'force-dynamic';

// 静的レンダリングを強制
export const dynamic = 'force-static';

// 自動(デフォルト)- Next.jsが決定
export const dynamic = 'auto';

// 動的な場合はエラー
export const dynamic = 'error';

// 再検証間隔(秒)
export const revalidate = 60;

// 再検証なし(永久に静的)
export const revalidate = false;

// リクエストごとに再検証
export const revalidate = 0;

タグベースの再検証

タグの設定

// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts', {
    next: { tags: ['posts'] },
  });
  return res.json();
}

async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`, {
    next: { tags: ['posts', `post-${id}`] },
  });
  return res.json();
}

タグの再検証

// app/actions.ts
'use server';

import { revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.posts.create({ /* ... */ });

  // 'posts'タグを持つすべてのリクエストを再検証
  revalidateTag('posts');
}

export async function updatePost(id: string, formData: FormData) {
  await db.posts.update(id, { /* ... */ });

  // 特定の投稿とリストを再検証
  revalidateTag(`post-${id}`);
  revalidateTag('posts');
}

静的 vs 動的レンダリング

flowchart TD
    subgraph Static["静的レンダリング"]
        A[ビルド時] --> B[HTML生成]
        B --> C[CDNから配信]
    end

    subgraph Dynamic["動的レンダリング"]
        D[リクエスト時] --> E[データフェッチ]
        E --> F[HTMLレンダリング]
        F --> G[レスポンス配信]
    end

    style Static fill:#10b981,color:#fff
    style Dynamic fill:#3b82f6,color:#fff

generateStaticParamsでの静的生成

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return <article>{post.content}</article>;
}

動的関数

これらの関数を使用するとルートが動的になります:

import { cookies, headers } from 'next/headers';
import { searchParams } from 'next/navigation';

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  // cookiesを使用するとルートが動的に
  const cookieStore = await cookies();
  const theme = cookieStore.get('theme');

  // headersを使用するとルートが動的に
  const headersList = await headers();
  const userAgent = headersList.get('user-agent');

  // searchParamsを使用するとルートが動的に
  const params = await searchParams;
  const query = params.q;

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

リクエストのメモ化

Next.jsは同一のfetchリクエストを自動的に重複排除します:

// このフェッチは複数の場所で呼び出される
async function getUser() {
  // 同じURL = 同じリクエスト(重複排除)
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

// レイアウトがユーザーをフェッチ
export default async function Layout({ children }) {
  const user = await getUser(); // リクエスト#1
  return <div>{children}</div>;
}

// ページもユーザーをフェッチ
export default async function Page() {
  const user = await getUser(); // 重複排除 - #1を再利用
  return <div>{user.name}</div>;
}

// コンポーネントもユーザーをフェッチ
async function UserProfile() {
  const user = await getUser(); // 重複排除 - #1を再利用
  return <div>{user.email}</div>;
}

エラーハンドリング

エラー境界

// app/posts/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>投稿の読み込みに失敗しました</h2>
      <p>{error.message}</p>
      <button onClick={reset}>もう一度試す</button>
    </div>
  );
}

優雅なエラーハンドリング

// app/posts/page.tsx
async function getPosts() {
  try {
    const res = await fetch('https://api.example.com/posts');

    if (!res.ok) {
      throw new Error(`HTTPエラー: ${res.status}`);
    }

    return res.json();
  } catch (error) {
    console.error('投稿の取得に失敗:', error);
    return []; // フォールバックとして空配列を返す
  }
}

export default async function PostsPage() {
  const posts = await getPosts();

  if (posts.length === 0) {
    return <p>投稿がありません</p>;
  }

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

データフェッチパターン

ウォーターフォールパターン(避ける)

// ❌ 遅い - 順次フェッチ
export default async function Page() {
  const user = await getUser();           // 待機
  const posts = await getPosts(user.id);  // さらに待機
  const comments = await getComments();    // さらに待機

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

並列パターン(推奨)

// ✅ 高速 - 並列フェッチ
export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ]);

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

プリロードパターン

// lib/data.ts
import { cache } from 'react';

export const getUser = cache(async (id: string) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
});

export const preloadUser = (id: string) => {
  void getUser(id);
};
// app/user/[id]/page.tsx
import { getUser, preloadUser } from '@/lib/data';

export default async function UserPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;

  // 早めにフェッチを開始
  preloadUser(id);

  // ... 他のコード

  const user = await getUser(id); // プリロードされたデータを使用

  return <div>{user.name}</div>;
}

Suspenseでストリーミング

import { Suspense } from 'react';

async function SlowComponent() {
  const data = await fetch('https://api.example.com/slow');
  return <div>{data}</div>;
}

export default function Page() {
  return (
    <div>
      <h1>ダッシュボード</h1>

      {/* 高速なコンテンツは即座に表示 */}
      <p>ダッシュボードへようこそ</p>

      {/* 遅いコンテンツは後でストリーム */}
      <Suspense fallback={<p>読み込み中...</p>}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

まとめ

概念 使い方
cache: 'force-cache' 無期限にキャッシュ(デフォルト)
cache: 'no-store' キャッシュしない
next: { revalidate: N } N秒間キャッシュ
next: { tags: [...] } オンデマンド再検証用タグ
revalidateTag() タグ付きキャッシュをパージ
revalidatePath() パスキャッシュをパージ
generateStaticParams 動的ルートの静的生成
export const dynamic ルートセグメント設定

重要なポイント:

  • Server Componentsはasync/awaitを使って直接データをフェッチできる
  • fetchリクエストはデフォルトでキャッシュされる;リアルタイムデータにはno-storeを使用
  • 時間ベースのキャッシュ無効化にはrevalidateを使用
  • きめ細かいオンデマンド再検証にはタグを使用
  • Promise.allでの並列フェッチは順次より高速
  • リクエストのメモ化は同一のフェッチを重複排除
  • 遅いデータのストリーミングにはSuspenseを使用
  • generateStaticParamsは動的ルートの静的生成を可能に

これらのパターンを理解することで、高速で効率的なNext.jsアプリケーションを構築できます。

参考文献