Next.js App Routerのデータフェッチで詰まった!RSCとキャッシュの挙動と解決策
Next.js App Routerを導入したものの、「データが更新されない」「なぜかAPIが何度も呼ばれる」「ハイドレーションエラーで困っている」といった経験はありませんか? 従来のPages Routerからデータフェッチの仕組みが大きく変わったため、多くの開発者がNext.js App Routerのキャッシュ挙動やReact Server Components(RSC)との連携に戸惑いがちです。
この記事では、Next.js 14.xにおけるApp Routerでのデータフェッチとキャッシュの挙動を深く掘り下げ、RSCとクライアントコンポーネント間でのデータ受け渡し、fetch APIの拡張、そしてNext.jsの強力なキャッシュ機構をどのように活用すべきか、実務でハマりやすいポイントとその具体的な解決策をコード例を交えて解説します。この記事を読めば、Next.jsアプリケーションのパフォーマンスを最大化し、開発体験を向上させるためのデータフェッチの最適解が手に入ります。
App Routerにおけるデータフェッチの基本概念
Next.js App Routerでは、React Server Components(RSC)がデフォルトで利用され、データフェッチのパラダイムがPages Router時代から大きく変化しました。このセクションでは、App Router環境でのデータフェッチの基本と、主要な概念について解説します。
React Server Components (RSC) とは
Next.js 13で導入されたappディレクトリでは、デフォルトでReact Server Components (RSC) が使用されます。RSCはサーバー上でレンダリングされ、クライアントに送信されるのは最終的なHTML/CSSと、最小限のJavaScriptのみです。これにより、バンドルサイズを削減し、初回ロード時のパフォーマンスを向上させます。
RSCの大きな特徴は、JavaScript Promisesを直接サポートしている点です。これにより、useEffectやuseStateを使わずに、async/await構文で直接コンポーネント内で非同期処理(データフェッチなど)を実行できます。
// app/page.tsx (Server Component)
// デフォルトでServer Componentとして扱われる
interface Post {
id: number;
title: string;
body: string;
}
async function getPosts(): Promise<Post[]> {
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
return res.json();
}
export default async function Page() {
const posts = await getPosts(); // コンポーネント内で直接データをフェッチ
return (
<div>
<h1>投稿一覧</h1>
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
fetch APIの拡張とNext.jsのキャッシュ
Next.jsは、ブラウザのネイティブfetch APIを拡張し、強力なキャッシュと再検証機能を追加しています。Server Components内でのfetchリクエストは、デフォルトでNext.jsのData Cacheに自動的にキャッシュされます。
Data Cacheは永続的なHTTPキャッシュであり、fetchによって返された値をサーバー上で保存します。これにより、同じリクエストが複数回発生しても、再フェッチすることなくキャッシュされたデータを再利用できます。このキャッシュは、ビルド時またはリクエスト時にデータをフェッチし、再利用するのに役立ちます。
Next.js App Routerにおけるキャッシュ制御の詳解
Next.js App Routerのデータフェッチでは、キャッシュの挙動を理解し適切に制御することがパフォーマンス最適化の鍵となります。ここでは、fetch APIのキャッシュ制御オプションとReact.cacheについて詳しく見ていきましょう。
fetchのキャッシュ制御オプション
fetch APIには、キャッシュの挙動を細かく制御するためのオプションが用意されています。
1. キャッシュの無効化 (cache: 'no-store')
常に最新のデータを取得したい場合は、cache: 'no-store'オプションを使用します。これにより、Next.jsのData Cacheは利用されず、リクエストごとにデータが再フェッチされます。
// app/fresh-data/page.tsx
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
export default async function FreshDataPage() {
// キャッシュを無効にし、常に最新のデータを取得
// 開発環境ではリクエストごとにフェッチされるが、本番環境ではビルド時またはリクエスト時にキャッシュされ、
// 'no-store' が指定されている場合は常に再フェッチされる。
const res = await fetch('https://jsonplaceholder.typicode.com/todos/1', { cache: 'no-store' });
const todo: Todo = await res.json();
return (
<div>
<h1>Fresh Todo (cache: 'no-store')</h1>
<p>ID: {todo.id}</p>
<p>Title: {todo.title}</p>
<p>Completed: {todo.completed ? 'Yes' : 'No'}</p>
{/* 常に新しい時刻が表示されることを確認 */}
<p>Fetched at: {new Date().toLocaleTimeString()}</p>
</div>
);
}
2. 特定の時間での再検証 (next: { revalidate: number | false })
Incremental Static Regeneration (ISR) のような挙動を実現したい場合は、next: { revalidate: number }オプションを使用します。これにより、指定した時間(秒)が経過した後にデータがバックグラウンドで再検証されます。revalidate: 0はcache: 'no-store'と同じ挙動になります。
// app/revalidated-data/page.tsx
interface User {
id: number;
name: string;
username: string;
email: string;
}
export default async function RevalidatedDataPage() {
// 60秒ごとにデータを再検証
// 初回リクエスト時にフェッチされ、その後60秒間はキャッシュが利用される。
// 60秒経過後の次のリクエストでバックグラウンドで再フェッチされ、新しいデータが提供される。
const res = await fetch('https://jsonplaceholder.typicode.com/users/1', { next: { revalidate: 60 } });
const user: User = await res.json();
return (
<div>
<h1>User (Revalidate every 60s)</h1>
<p>Name: {user.name}</p>
<p>Email: {user.email}</p>
{/* 60秒間は同じ時刻が表示され、その後更新されることを確認 */}
<p>Fetched at: {new Date().toLocaleTimeString()}</p>
</div>
);
}
React.cacheによるリクエスト内での重複フェッチ防止
同じリクエスト内で複数のコンポーネントが同じデータをフェッチする場合、React.cacheでデータフェッチ関数をラップすることで、重複したフェッチを防ぎ、1つの結果を共有できます。これは、現在のリクエストにのみスコープされ、リクエスト間で共有されるグローバルキャッシュではありません。
// lib/data.ts
import { cache } from 'react'; // React.cache をインポート
interface Post {
id: number;
title: string;
body: string;
}
// React.cache を使用して、同じリクエスト内での重複フェッチを防ぐ
export const getPosts = cache(async (): Promise<Post[]> => {
console.log('Fetching posts...'); // サーバー側で一度だけ実行されることを確認
const res = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!res.ok) {
throw new Error('Failed to fetch posts');
}
return res.json();
});
// app/page.tsx (Server Component)
import { getPosts } from '../lib/data';
import PostList from './components/PostList';
export default async function Page() {
const posts = await getPosts(); // キャッシュされる
return (
<div>
<h1>Posts from Page</h1>
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<hr />
{/* 同じリクエスト内でgetPostsが再利用される */}
<PostList />
</div>
);
}
// app/components/PostList.tsx (別のServer Component)
import { getPosts } from '../../lib/data'; // 同じgetPosts関数をインポート
export default async function PostList() {
// 同じリクエスト内であれば、getPostsは一度しか実行されないため、キャッシュが利用される
const posts = await getPosts();
return (
<div>
<h2>More Posts from Component</h2>
<ul>
{posts.slice(0, 3).map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
);
}
console.log('Fetching posts...'); は、app/page.tsxとapp/components/PostList.tsxの両方でgetPostsを呼び出しても、サーバーのコンソールには一度しか出力されないことを確認できます。これはReact.cacheが正しく機能し、同じリクエスト内で関数呼び出しの結果をメモ化しているためです。
クライアントコンポーネントでのデータフェッチ
Server Componentsがデータフェッチの主役ですが、インタラクティブなUIを持つクライアントコンポーネントでは、別途データをフェッチする必要がある場合があります。このセクションでは、クライアントコンポーネントでのデータフェッチの推奨パターンと、Route Handlersの活用について説明します。
Route Handlersの活用
クライアントコンポーネントでデータをフェッチする必要がある場合、Next.jsのRoute Handlers (旧API Routes) を呼び出すのが一般的なパターンです。Route Handlerはサーバー上で実行されるため、データベースアクセスや外部APIへの認証情報など、機密情報をクライアントに公開することなくデータを取得し、クライアントに返すことができます。
// app/api/data/route.ts (Route Handler)
import { NextResponse } from 'next/server';
export async function GET() {
// サーバーサイドで実行されるため、機密情報へのアクセスが可能
const serverData = { message: 'Hello from API!', timestamp: new Date().toISOString() };
return NextResponse.json(serverData);
}
// app/client-component-fetch/page.tsx
"use client"; // クライアントコンポーネントであることを明示
import { useEffect, useState } from 'react';
interface ApiData {
message: string;
timestamp: string;
}
export default function ClientFetchPage() {
const [data, setData] = useState<ApiData | null>(null);
const [loading, setLoading] = useState(true);
const [error, useStateError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
const res = await fetch('/api/data'); // Route Handlerを呼び出す
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const result: ApiData = await res.json();
setData(result);
} catch (e: any) {
useStateError(e.message);
} finally {
setLoading(false);
}
};
fetchData();
}, []); // 依存配列が空なので、コンポーネントのマウント時に一度だけ実行される
if (loading) return <p>Loading...</p>;
if (useStateError) return <p>Error: {useStateError}</p>;
return (
<div>
<h1>Client Component Fetch</h1>
<p>{data?.message}</p>
<p>Timestamp from API: {data?.timestamp}</p>
</div>
);
}
クライアントサイドデータフェッチライブラリの検討
SWRやTanStack Query (React Query) のようなクライアントサイドデータフェッチライブラリを利用することで、キャッシュ、再検証、重複排除、エラーハンドリング、ローディング状態の管理といった高度な機能を簡単に扱えます。特に複雑なクライアントサイドのデータフローが必要な場合は、これらのライブラリの導入を検討すると良いでしょう。
App Routerデータフェッチでよくあるハマりどころと解決策
Next.js App Routerでのデータフェッチは強力ですが、 Pages Routerからの移行やRSCの特性を理解していないと、思わぬ落とし穴にハマることがあります。ここでは、よくある問題とその解決策を具体的に示します。
1. Server Componentでの不必要なRoute Handler呼び出し
ハマりどころ:
Server Component内で、同じNext.jsアプリケーション内のRoute Handlerをfetchで呼び出してしまうケースです。これは不要なネットワークホップを発生させ、パフォーマンスを低下させます。Server ComponentもRoute Handlerもサーバー上で実行されるため、間にHTTPリクエストを挟む必要はありません。
回避策:
Server Componentでは、Route Handler内のロジックを直接呼び出すか、外部APIを直接fetchで呼び出します。サーバーサイドでのみ実行される関数は、server-onlyパッケージでマークすると、誤ってクライアントでインポートされるのを防ぐことができます。
// ❌ 悪い例 (Server ComponentからRoute Handlerをfetch)
// app/page.tsx
// 開発環境では 'http://localhost:3000' が必要になる場合があるが、
// 本番環境では同じサーバー内でのHTTPリクエストは不要。
// 外部APIへのfetchとは異なり、内部APIへのfetchはオーバーヘッド。
export default async function Page() {
let res = await fetch('http://localhost:3000/api/internal-data');
let data = await res.json();
return <h1>{JSON.stringify(data)}</h1>;
}
// app/api/internal-data/route.ts
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ data: 'Internal API Data' });
}
// ✅ 良い例 (Server Componentで直接ロジックを呼び出す)
// lib/data.ts
import 'server-only'; // このファイルがサーバーでのみ使用されることを保証
export async function getInternalData() {
// データベースアクセスや外部API呼び出しなど、サーバーサイドのロジックを直接実行
return { data: 'Direct Server Data' };
}
// app/page.tsx
import { getInternalData } from '../lib/data';
export default async function Page() {
let data = await getInternalData();
return <h1>{JSON.stringify(data)}</h1>;
}
2. クライアントコンポーネントでの重複データフェッチ
ハマりどころ:
クライアントコンポーネントでuseEffectを使用してデータをフェッチする際に、依存配列が不適切であるか、状態管理が不十分なために、コンポーネントが再レンダリングされるたびにデータが再フェッチされてしまうことがあります。
回避策:
useEffectの依存配列を適切に設定し、不要な再フェッチを防ぎます。また、SWRやTanStack Queryのようなクライアントサイドデータフェッチライブラリを利用することで、キャッシュ、再検証、重複排除などの高度な機能を活用できます。
// ❌ 悪い例 (依存配列が不適切で再フェッチの可能性)
// "use client";
// import { useEffect, useState } from 'react';
// export default function MyComponent({ userId }: { userId: string }) {
// const [user, setUser] = useState(null);
// useEffect(() => {
// // userIdが変わるたびにフェッチされるべきだが、依存配列にuserIdがないと初回のみ
// fetch(`/api/users/${userId}`).then(res => res.json()).then(setUser);
// }, []); // 依存配列が空だとuserIdが変わってもフェッチされない
// return <p>{user?.name}</p>;
// }
// ✅ 良い例 (依存配列を適切に設定)
"use client";
import { useEffect, useState } from 'react';
export default function MyComponent({ userId }: { userId: string }) {
const [user, setUser] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data))
.catch(error => console.error('Fetch error:', error))
.finally(() => setLoading(false));
}, [userId]); // userIdが変わった時のみ再フェッチ
if (loading) return <p>Loading user...</p>;
if (!user) return <p>No user found.</p>;
return <p>User: {user.name}</p>;
}
// ✅ 良い例 (SWRなどのライブラリを使用)
// "use client";
// import useSWR from 'swr';
// const fetcher = (url: string) => fetch(url).then(res => res.json());
// export default function MyComponent({ userId }: { userId: string }) {
// const { data, error, isLoading } = useSWR(`/api/users/${userId}`, fetcher);
// if (isLoading) return <p>Loading user...</p>;
// if (error) return <p>Error: {error.message}</p>;
// return <p>User: {data.name}</p>;
// }
3. ハイドレーションエラー
ハマりどころ:
サーバーとクライアントでレンダリングされるマークアップに不一致がある場合に発生します。特に、DateオブジェクトやMath.random()、またはクライアントでのみ利用可能なグローバルオブジェクト(windowなど)をServer Component内で使用すると、サーバーとクライアントで生成されるHTMLが異なり、ハイドレーションエラーが発生しやすくなります。
回避策:
非決定的な値(random、date、windowなど)はServer Componentでは使用しないようにします。クライアントでのみ必要な値はuseEffect内で設定するか、"use client"ディレクティブを持つClient Component内で処理します。
// ❌ Buggy (Server Componentで非決定的な値を使用)
// app/timestamp/page.tsx
export default function Timestamp() {
// サーバーとクライアントでレンダリングされる時刻が異なるためハイドレーションエラー
return <p>Current time: {new Date().toLocaleTimeString()}</p>;
}
// ✅ Fixed (Client ComponentでuseEffectを使用)
// app/timestamp-client/page.tsx
"use client";
import { useEffect, useState } from "react";
export default function TimestampClient() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
// クライアント側でマウント後に時刻を設定するため、ハイドレーションエラーを回避
setTime(new Date().toLocaleTimeString());
}, []);
return <p>Current time (client): {time ?? "Loading..."}</p>;
}
4. キャッシュの意図しない利用
ハマりどころ:
fetchがデフォルトでキャッシュされることを理解せず、常に最新のデータが取得されると誤解してしまうことです。特に開発環境ではキャッシュの挙動が本番環境と異なる場合があり、混乱を招くことがあります。
回避策:
常に最新のデータが必要な場合は{ cache: 'no-store' }オプションを明示的に指定します。定期的な再検証が必要な場合は{ next: { revalidate: seconds } }を使用します。fetchのデフォルトキャッシュ挙動を意識し、必要に応じて制御オプションを適用することが重要です。
Next.js App Routerデータフェッチのベストプラクティス
App Routerでのデータフェッチは、適切に設計することでアプリケーションのパフォーマンスとメンテナンス性を大きく向上させることができます。ここでは、いくつかのベストプラクティスを紹介します。
Server Componentsをデフォルトにする
可能な限りServer Componentsでデータをフェッチすることを推奨します。Server Componentsの利用には以下のメリットがあります。
- バックエンドデータリソースへの直接アクセス(データベースクエリなど)
- APIトークンなどの機密情報のクライアントへの漏洩防止
- クライアントとサーバー間の通信の削減
- 複数データフェッチの単一ラウンドトリップ化
- クライアントスレッドの負荷軽減
必要な場所でデータをフェッチする
Pages Router時代のように、データのバケツリレー(props drilling)を避けるためにグローバルなデータフェッチを行う必要はありません。複数のコンポーネントで同じデータが必要な場合でも、fetchまたはReact.cacheをデータが必要なコンポーネント内で使用することで、パフォーマンスへの影響を心配することなく、同じリクエスト内でデータがメモ化されます。
クライアントコンポーネントの境界を狭くする
"use client"を使用するクライアントコンポーネントは、必要なインタラクティビティを持つコンポーネント内に限定し、できるだけツリーの奥深く配置します。サーバー専用のオブジェクト(データベースハンドルなど)をクライアントコンポーネントに直接渡すのは避けるべきです。
ストリーミングとSuspenseの活用
データフェッチに時間がかかる部分にはloading.jsやSuspenseを使用し、即座にレンダリングできる部分からUIを表示することで、ユーザー体験を向上させます。これにより、LCP (Largest Contentful Paint) の改善にも繋がります。
並列データフェッチと逐次データフェッチ
関連性のない複数のデータフェッチはPromise.allなどを使用して並列に実行し、データフェッチのウォーターフォールを削減します。依存関係のあるデータフェッチは、その依存関係が解決されてから次のフェッチを実行します。
// 並列フェッチの例
async function getParallelData() {
const [posts, users] = await Promise.all([
fetch('https://jsonplaceholder.typicode.com/posts').then(res => res.json()),
fetch('https://jsonplaceholder.typicode.com/users').then(res => res.json()),
]);
return { posts, users };
}
// 逐次フェッチの例 (ユーザーIDから投稿を取得する場合など)
async function getSequentialData(userId: number) {
const user = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`).then(res => res.json());
const postsByUser = await fetch(`https://jsonplaceholder.typicode.com/posts?userId=${user.id}`).then(res => res.json());
return { user, postsByUser };
}
エラーハンドリングとローディング状態の管理
データフェッチの失敗に備えて適切なエラーハンドリング(try...catchブロックやエラーバウンダリ)を実装し、データ取得中にはloading.jsファイルやSuspenseを使ってローディングUIを表示することで、ユーザーにスムーズな体験を提供します。
まとめ
Next.js App Routerにおけるデータフェッチとキャッシュは、アプリケーションのパフォーマンスを大きく左右する重要な要素です。
本記事では、以下の点について解説しました。
-
React Server Components (RSC) のデータフェッチ能力と、
fetchAPIの拡張による強力なキャッシュ機構。 -
キャッシュの制御方法:
cache: 'no-store'によるキャッシュ無効化、next: { revalidate: seconds }による再検証設定。 -
React.cacheを用いた同一リクエスト内での重複フェッチ防止。 - クライアントコンポーネントでのデータフェッチにおけるRoute Handlersの活用。
- よくあるハマりどころとして、「Server ComponentでのRoute Handlerの不必要な呼び出し」「クライアントコンポーネントでの重複フェッチ」「ハイドレーションエラー」「キャッシュの意図しない利用」を挙げ、具体的な解決策を提示しました。
- ベストプラクティスとして、Server Componentsをデフォルトにすること、必要な場所でデータをフェッチすること、クライアントコンポーネントの境界を狭くすることなどを提案しました。
Next.js App Routerのデータフェッチとキャッシュの挙動を正しく理解し、これらの知見を実践することで、より高速で堅牢なNext.jsアプリケーションを構築できるはずです。さらに深く学びたい方は、Next.jsの公式ドキュメントを参照することをお勧めします。