データフェッチとキャッシュ
Server Component で `async / await` でデータを直接取ってくるパターン、Next.js 15 の fetch キャッシュオプション(`force-cache` / `no-store` / `revalidate`)、ルートレベルのオプション、そして並列フェッチと `error.tsx` まで。
第24章で Server と Client の境界を整理しました。本章では Server Component の最も強力な機能 — データフェッチが単純になる という点を本格的に扱います。
本章のパターンは、第10章(useEffect)と第21章(fetch と API の型付け)で見たクライアントサイドフェッチのボイラープレートがどう消えるかを正面から示します。そして続く 第26章 Suspense と use()で、本章のフェッチの上に段階的なレンダリングを重ねていきます。
クライアントサイドフェッチの複雑さ #
覚えていますか。第10章で useEffect でデータを取ってくるときのパターン。
'use client';
import { useEffect, useState } from 'react';
type User = { id: number; name: string };
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => { if (!cancelled) setUser(data); })
.catch(err => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [userId]);
if (loading) return <p>ローディング中...</p>;
if (error) return <p>エラー: {error}</p>;
if (!user) return null;
return <p>{user.name}</p>;
}これが標準パターンでした。3 つの state、useEffect、race condition の処理、ローディング / エラー分岐まで。同じ作業のボイラープレートが毎回繰り返されます。
Server Component では #
同じことを Server Component でやるとこうなります。
type User = { id: number; name: string };
type Props = {
params: Promise<{ userId: string }>;
};
export default async function UserProfile({ params }: Props) {
const { userId } = await params;
const user: User = await fetch(`https://api.example.com/users/${userId}`)
.then(res => res.json());
return <p>{user.name}</p>;
}これで全部です。違いが一目で分かります。
- state を作る必要なし(サーバーで一度実行されて終わりなので state の概念自体がない)
- ローディング状態を気にする必要なし(フェッチが終わってから HTML がクライアントに行くので、「ローディング中」の状態がクライアントには存在しない)
- race condition なし(サーバーで一度実行、終わり)
- エラーはただ throw → 最寄りの
error.tsxがキャッチ
この単純さが Server Component が解決する核心的な価値の 1 つです。第21章で定義した User 型をそのまま持ってきて使いつつ、同時にクライアントに行くコードがほとんどないコードです。
直接 fetch 以外の選択肢 #
Server Component はサーバーで実行されるので、クライアントができないこと も可能です。
DB を直接クエリ #
import { db } from '@/lib/db';
type Post = { slug: string; title: string; content: string };
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await db.query<Post>('SELECT * FROM posts WHERE slug = $1', [slug]);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}API を別に作る必要がありません。クライアントサイド React では絶対にできないことです。ブラウザで DB 認証情報を露出させたらセキュリティ事故だからです。Server Component では認証情報がクライアントに行かないので安全です。
ファイルシステムを読む #
import fs from 'node:fs/promises';
import path from 'node:path';
type Props = {
params: Promise<{ slug: string }>;
};
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const filePath = path.join(process.cwd(), 'posts', `${slug}.mdx`);
const content = await fs.readFile(filePath, 'utf-8');
// ... MDX をコンパイル ...
return <article>{/* ... */}</article>;
}fs のような Node.js モジュールを Server Component でそのまま import して使えます。同じファイルに 'use client' があったらビルドエラーになっていたコードが、Server Component では自然に動作します。
Next.js の fetch キャッシュ #
Next.js は fetch をもう一段ラップして 自動キャッシュ を提供します。同じ URL を何度も fetch しても(同じリクエスト内なら)実際の呼び出しは一度だけ起きます。そしてビルド時点やランタイムでのキャッシュ動作も制御できます。
デフォルト動作 — リクエスト内 dedup #
同じページ内で複数のコンポーネントが同じデータを fetch しても、実際には一度だけ呼び出されます。
async function getUser(id: number) {
return fetch(`https://api.example.com/users/${id}`).then(r => r.json());
}
export default async function Page() {
const userA = await getUser(1); // 実際の呼び出し
const userB = await getUser(1); // キャッシュから取得(自動)
// ...
return null;
}getUser を 2 回呼びましたが、実際の HTTP リクエストは 1 回だけ起きます。Next.js が同じページのレンダリング中の同一 fetch を重複除去(deduplication)してくれます。
キャッシュオプション — cache と next.revalidate
#
fetch の第 2 引数でキャッシュ動作を制御できます。
// 1. 永久キャッシュ(静的データ、ほぼ変わらない)
fetch(url, { cache: 'force-cache' });
// 2. キャッシュしない(毎回新しくリクエスト)
fetch(url, { cache: 'no-store' });
// 3. N 秒ごとに再検証(よく変わるデータ)
fetch(url, { next: { revalidate: 60 } });
// 4. タグベースの再検証(手動で無効化可能)
fetch(url, { next: { tags: ['posts'] } });それぞれどんな場面に使うか。
force-cache— ビルド時に一度取ってきて永久キャッシュ。ほぼ変わらないデータ(静的ページ情報、カテゴリ一覧など)no-store— 常に新しく取得。ユーザーごとに異なるデータ、リアルタイム性が重要な情報revalidate: 60— 60 秒間はキャッシュ、その後の最初のリクエストでバックグラウンド更新。ブログ記事の一覧のような「ほぼ静的だがたまに変わる」tags— コードからrevalidateTag('posts')を呼び出して手動で無効化。記事が登録・削除されるとき
デフォルト値は Next.js 15 から no-store(つまりキャッシュしない)に変わりました。以前のバージョンに基づく資料では force-cache がデフォルトと書かれていることがありますが、最新基準では 明示的に cache オプションを設定する のが良いです。
cache: 'no-store' から始めて安全に動作させ、その後パフォーマンスが必要なときにキャッシュを追加していくアプローチが安全です。ルートレベルのオプション — revalidate、dynamic
#
ページ単位で動作を制御することもできます。
export const revalidate = 60; // このページ全体を 60 秒ごとに再生成
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return <ul>{/* ... */}</ul>;
}export const dynamic = 'force-dynamic'; // 毎リクエストごとに新たにレンダリング(キャッシュなし)
ページ全体のキャッシュポリシーを一行で表現するわけです。データがよく変わるページには dynamic を、ほぼ変わらないページには revalidate を使います。
並列フェッチ — Promise.all #
複数のデータを取ってこなければならないとき、順次 await を使うとウォーターフォールになります。
const user = await getUser(id); // 100ms かかる
const posts = await getPosts(id); // さらに 100ms。合計 200ms
データが互いに依存しないなら 並列で 取ってくる方が良いです。
const [user, posts] = await Promise.all([
getUser(id),
getPosts(id),
]);
// 両方同時に開始 → 100ms(最も遅い側を基準)
Promise.all で束ねると 2 つのリクエストが同時に始まります。Server Component でよく使うパターンです。
もっと良い方法 — コンポーネントごとに分離 #
各データを自分を使うコンポーネントで直接取ってくれば、Next.js の自動 dedup と結合して非常に自然な並列処理になります。
type Props = {
params: Promise<{ id: string }>;
};
export default async function UserPage({ params }: Props) {
const { id } = await params;
return (
<div>
<UserHeader userId={id} />
<UserPosts userId={id} />
</div>
);
}
async function UserHeader({ userId }: { userId: string }) {
const user = await getUser(userId);
return <h1>{user.name}</h1>;
}
async function UserPosts({ userId }: { userId: string }) {
const posts = await getPosts(userId);
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}Server Component は 自分自身が async 関数 であり得るので、上のように下位コンポーネントもそれぞれデータフェッチができます。React がそれらを並列実行してくれるからです。
このパターンのもう 1 つの利点は 各部分が自分のデータだけに依存する という点です。UserHeader は UserPosts のデータが遅く来ても来なくても自分の仕事を進められ、続く第26章の Suspense と組み合わせれば、素早く準備された部分から画面に見せる streaming も可能になります。
エラー処理 — error.tsx #
Server Component でフェッチが失敗したら、ただ throw すればよいです。Next.js は最寄りの error.tsx を探して表示します。
'use client';
type Props = {
error: Error & { digest?: string };
reset: () => void;
};
export default function ErrorBoundary({ error, reset }: Props) {
return (
<div style={{ padding: '24px' }}>
<h2>問題が発生しました。</h2>
<p>{error.message}</p>
<button onClick={reset}>再試行</button>
</div>
);
}このファイルがあると、/posts またはその下位ページでエラーが起きるとこの画面が見えます。'use client' が必要な理由は、reset がクライアントサイドの動作(onClick イベントなど)だからです。
試してみる — GitHub API で小さなサイト #
第23章のサイトを実際のデータを取ってくる形に発展させてみます。GitHub の公開 API を使います(認証なしだと時間あたりの制限がありますが、学習用には十分)。
src/app/repos/[owner]/[repo]/page.tsx:
type Repo = {
full_name: string;
description: string | null;
stargazers_count: number;
forks_count: number;
watchers_count: number;
language: string | null;
html_url: string;
};
type Props = {
params: Promise<{ owner: string; repo: string }>;
};
export default async function RepoPage({ params }: Props) {
const { owner, repo } = await params;
const data: Repo = await fetch(
`https://api.github.com/repos/${owner}/${repo}`,
{ next: { revalidate: 300 } } // 5 分キャッシュ
).then(res => {
if (!res.ok) throw new Error('Repo not found');
return res.json();
});
return (
<div style={{ padding: '24px' }}>
<h1>{data.full_name}</h1>
<p>{data.description}</p>
<ul>
<li>⭐ {data.stargazers_count.toLocaleString()}</li>
<li>🍴 {data.forks_count.toLocaleString()}</li>
<li>👁 {data.watchers_count.toLocaleString()}</li>
<li>主な言語: {data.language ?? '不明'}</li>
</ul>
<a href={data.html_url} target="_blank" rel="noopener">GitHub で見る</a>
</div>
);
}src/app/repos/error.tsx:
'use client';
type Props = {
error: Error & { digest?: string };
reset: () => void;
};
export default function ErrorBoundary({ error, reset }: Props) {
return (
<div style={{ padding: '24px' }}>
<h2>リポジトリが見つかりません</h2>
<p style={{ color: '#888', fontSize: '14px' }}>{error.message}</p>
<button onClick={reset}>再試行</button>
</div>
);
}src/app/page.tsx にリンクを追加:
import Link from 'next/link';
export default function HomePage() {
return (
<div style={{ padding: '24px' }}>
<h1>GitHub リポジトリを見る</h1>
<ul>
<li><Link href="/repos/facebook/react">facebook/react</Link></li>
<li><Link href="/repos/vercel/next.js">vercel/next.js</Link></li>
<li><Link href="/repos/curtisdev/this-does-not-exist">存在しないリポジトリ</Link></li>
</ul>
</div>
);
}各リンクを押してみてください。
- 正常なリポジトリ: GitHub から取ってきた情報が画面に表示される
- 存在しないリポジトリ:
error.tsxが割り込みエラー画面が表示される - 5 分以内に再訪問: キャッシュされた結果が即座に表示される
ここで起きたすべての処理が サーバー側で 行われました。ブラウザに送られる JavaScript はほとんどありません。ページを見るユーザーの立場からは通常の静的 HTML と区別できない素早い応答であり、開発者の立場からはただ await fetch(...) 一行で終わる単純なコードです。
練習問題 #
- dedup を直接確認する。上の RepoPage で同じ fetch を 2 回呼ぶように作ってみてください。
console.time/console.timeEndで時間を測り、キャッシュオプションをcache: 'no-store'に変えて、同一 fetch が実際に 2 回発生するかを比較します。dev サーバーのコンソールに表示される fetch 回数も一緒に観察してみてください。 - revalidate オプションの実験。
repos/[owner]/[repo]/page.tsxのrevalidate: 300をrevalidate: 5に変えた後、ページを再読み込みしたときに GitHub API のX-RateLimit-Remainingヘッダが 5 秒単位でのみ減るかを確認します。その後ページにexport const dynamic = 'force-dynamic'を追加して、毎リクエストごとに新たに fetch されるよう変えてみてください。 - 並列フェッチ vs コンポーネント別分離。1 ページで 2 人のユーザー情報を取ってくるコードを (a)
Promise.allで束ねる方式と (b) 2 つの子 Server Component がそれぞれ fetch する方式でそれぞれ書き、dev tools の Network タブでリクエストタイミングを比較します。両者がほぼ同じ時間を示す理由を本文の内容(自動 dedup + 子コンポーネント並列実行)で説明してみてください。
一行まとめ: Server Component のデータフェッチは
async function+await fetch(...)一行で終わり、ローディング / エラー / race condition のボイラープレートが消える。Next.js のfetchはリクエスト内自動 dedup と一緒に、cache/next.revalidate/next.tagsでキャッシュポリシーを細かく制御する(Next.js 15 からは明示的な指定が安全)。ルートレベルにはexport const revalidateとexport const dynamicがある。独立データはPromise.allまたは子コンポーネント別に fetch を分離して自然に並列処理し、エラーは throw →error.tsxがキャッチする。
次の章 #
次の 第26章 Suspense と use()では、本章で作ったページが すべてのデータが届くまで白い画面 という問題を解いてみます。<Suspense> と loading.tsx で準備された部分から段階的に見せる streaming を身につけ、React 19 で新たに安定化された use() フックで Server が作った Promise を Client で解いて使うパターンまで整理します。