モダンReact + Next.js #4 データフェッチとキャッシング

読了 9分

前回はServer / Clientコンポーネントの違いと境界を扱いました。今回はServer Componentの最も強力な能力 — データフェッチが単純になる点を本格的に見ていきます。

クライアントサイドフェッチの複雑さ #

覚えていますか? #10でuseEffectを使ってデータを取得するときのパターンです。

従来のクライアントサイドパターン
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(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>;
  return <p>{user.name}</p>;
}

これが標準パターンでした。3つのstate、useEffect、race condition処理、ローディング/エラー分岐まで — 同じ作業のボイラープレートが毎回繰り返されます。

Server Componentでは #

同じことをServer Componentで行うと次のようになります。

src/app/users/[userId]/page.js
export default async function UserProfile({ params }) {
  const { userId } = await params;
  const 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.jsがキャッチ

この単純さがServer Componentが解決する核心的な価値の1つです。

直接fetch以外のオプション #

Server Componentはサーバーで実行されるので、クライアントができないことも可能です。

DBへの直接クエリ #

DB から直接取得 (概念例)
import { db } from '@/lib/db';

export default async function PostPage({ params }) {
  const { slug } = await params;
  const post = await db.query('SELECT * FROM posts WHERE slug = $1', [slug]);

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  );
}

APIを別途作らなくても済みます。クライアントサイドReactでは絶対にできないことで — ブラウザでDB認証情報を露出するとセキュリティ事故になります。Server Componentでは認証情報がクライアントに送られないので安全です。

ファイルシステムの読み取り #

MDX ファイルを直接読む (このブログのやり方)
import fs from 'fs';
import path from 'path';

export default async function PostPage({ params }) {
  const { slug } = await params;
  const filePath = path.join(process.cwd(), 'posts', `${slug}.mdx`);
  const content = fs.readFileSync(filePath, 'utf-8');
  // ... MDX コンパイル ...
}

このブログ(schoolofweb.net)もまさにこの方式で動作します。posts/フォルダのMDXファイルをServer Componentで直接読み込み、コンパイルして画面に描画します。

Next.jsのfetchキャッシング #

Next.jsはfetchをさらに一段ラップして自動キャッシングを提供します。同じURLを何度fetchしても(同じリクエスト内なら)実際の呼び出しは一度だけ起こります。そしてビルド時点やランタイムでのキャッシング動作も制御できます。

デフォルト動作 — リクエスト内dedup #

同じページの中で複数のコンポーネントが同じデータをfetchしても、実際には一度だけ呼び出されます。

同じ fetch を複数回
async function getUser(id) {
  return fetch(`/api/users/${id}`).then(r => r.json());
}

export default async function Page() {
  const userA = await getUser(1);  // 実際の呼び出し
  const userB = await getUser(1);  // キャッシュから取得 (自動)
  // ...
}

getUserを2回呼び出しましたが、実際のHTTPリクエストは一度だけ起こります。Next.jsが同じページレンダリング中の同一fetchを重複排除(deduplication)してくれます。

キャッシュオプション — cachenext.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オプションを設定するのが良いです。

注記
キャッシングはNext.jsの最も強力な機能の1つであり、最も混乱する部分でもあります。この記事では基本だけを扱い、本格的なキャッシュ戦略は実際のプロジェクトの要件に合わせて決定してください。最初はcache: 'no-store'から始めて安全に動作させた後、性能が必要なときにキャッシュを追加していくアプローチが安全です。

ルートレベルのオプション — revalidatedynamic #

ページ単位で動作を制御することもできます。

src/app/posts/page.js
export const revalidate = 60;  // このページ全体を 60 秒ごとに再生成

export default async function PostsPage() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json());
  return /* ... */;
}
dynamic オプション
export const dynamic = 'force-dynamic';  // リクエストごとに新しくレンダ (キャッシュなし)

ページ全体のキャッシュポリシーを1行で表現するわけです。データが頻繁に変わるページには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と組み合わさって非常に自然な並列処理になります。

src/app/users/[id]/page.js
export default async function UserPage({ params }) {
  const { id } = await params;
  return (
    <div>
      <UserHeader userId={id} />
      <UserPosts userId={id} />
    </div>
  );
}

async function UserHeader({ userId }) {
  const user = await getUser(userId);
  return <h1>{user.name}</h1>;
}

async function UserPosts({ userId }) {
  const posts = await getPosts(userId);
  return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>;
}

Server Componentは自分自身がasync関数になり得るので、上のように下位コンポーネントもそれぞれデータフェッチができます。Reactがそれらを並列で実行してくれます。

このパターンのもう1つの利点は各部分が自分のデータだけに依存する点です。UserHeaderUserPostsのデータが遅く来ようと自分の仕事を進められ、#5で扱うSuspenseと組み合わせれば、早く準備できた部分から画面に表示するstreamingも可能になります。

エラー処理 — error.js #

Server Componentでフェッチが失敗したら、ただthrowすれば良いです。Next.jsは近いerror.jsを探して表示します。

src/app/posts/error.js
'use client';

export default function ErrorBoundary({ error, reset }) {
  return (
    <div style={{ padding: '24px' }}>
      <h2>問題が発生しました</h2>
      <p>{error.message}</p>
      <button onClick={reset}>再試行</button>
    </div>
  );
}

このファイルがあれば/postsまたはその下のページでエラーが出ると、この画面が表示されます。'use client'が必要な理由はresetがクライアントサイドの動作だからです。

動作確認 — GitHub APIで小さなサイト #

前回の記事のサイトを、本物のデータを取得する形に発展させましょう。GitHubの公開APIを使います (認証なしで時間あたりの制限がありますが学習用には十分)。

src/app/repos/[owner]/[repo]/page.js:

src/app/repos/[owner]/[repo]/page.js
export default async function RepoPage({ params }) {
  const { owner, repo } = await params;

  const data = 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.js:

src/app/repos/error.js
'use client';

export default function ErrorBoundary({ error, reset }) {
  return (
    <div style={{ padding: '24px' }}>
      <h2>リポジトリが見つかりません</h2>
      <p style={{ color: '#888', fontSize: '14px' }}>{error.message}</p>
      <button onClick={reset}>再試行</button>
    </div>
  );
}

src/app/page.jsにリンクを追加:

src/app/page.js
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.jsが割り込んでエラー画面が表示される
  • 5分以内に再訪問: キャッシュされた結果が即座に表示される

ここで起こったすべてがサーバーでです。ブラウザに送られるJavaScriptはほとんどありません。ページを見るユーザーから見れば普通の静的HTMLと区別がつかない速い応答であり、開発者から見ればただawait fetch(...)の1行で終わる単純なコードです。

まとめ #

今回の記事ではServer Componentのデータフェッチを扱いました。

  • async functionコンポーネント + await fetch(...)クライアントフェッチのボイラープレートが消える
  • DB / ファイルシステム / 環境変数などサーバーリソースに直接アクセス可能
  • Next.jsのfetchは自動dedup + キャッシュオプション (cachenext.revalidatetags)
  • ルートレベルのオプション (export const revalidateexport const dynamic)
  • 独立したデータはPromise.allまたはコンポーネントごとの分離で並列処理
  • エラーはthrow → error.jsがキャッチ

これまで作ったページはすべてのデータが届いてから初めて画面が表示されました。一方のデータが遅いと、速い方まで一緒に待たなければなりませんでした。次の記事「モダンReact + Next.js #5 Suspenseとuse()によるローディング処理」では、準備された部分から段階的に表示するstreaming、Suspense、loading.js、そして新しく登場したuse()フックを扱います。

X