Next.js App Router でのデータフェッチパターン:複数コンポーネントでのデータ共有と Suspense の使い分け
Next.js App Router で検索結果ページを実装する際の課題と解決策。Promise 共有と cache を活用した 2 つのアプローチで、複数コンポーネント間でのデータ共有とローディング状態の制御を実現する方法を比較検討します。
背景
Next.js App Router で、以下のような検索結果を表示するページを実装していました。
構成は以下の通り。
- Filter コンポーネント:検索フィルターとページネーション
- List コンポーネント:検索結果の表示
要件として、ページネーションに検索結果の総数が必要なため、両コンポーネントで同じ API レスポンスを共有する必要がありました。さらに、UX の観点から以下の制約がありました:
- List:データ取得中はローディング表示
- Filter:データ取得を待つが、ローディング表示はしない(フィルター UI が毎回消えるのは不自然なため)
最初のアプローチ
サーバーコンポーネントと Context を組み合わせた実装を行いました
- List(サーバーコンポーネント)でデータフェッチ
- Container(クライアントコンポーネント)で Context にデータを格納
- Filter(クライアントコンポーネント)で Context からデータを取得
コード例
page.tsx で呼び出される Collection コンポーネント
ProductsProvider で全体を囲み、Filter と List コンポーネントを配置
// app/_components/products-collection.tsx
import { Suspense } from "react";
import { Loading } from "@/components/ui/loading";
import { ProductsFilter } from "@/features/products/components/products-filter";
import { ProductsList } from "@/features/products/components/products-list";
import { ProductsProvider } from "@/features/products/providers/products-provider";
export function ProductsCollection({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
return (
<ProductsProvider>
<ProductsFilter />
<Suspense fallback={<Loading />}>
<ProductsList searchParams={searchParams} />
</Suspense>
</ProductsProvider>
);
}
ProductsList でサーバーからデータフェッチを行う
サーバーコンポーネントでのデータフェッチ中は Suspense で指定したローディングが表示される
// features/products/components/ProductsList.tsx
import { searchProducts } from "@/features/products/api/search-products";
import { ProductsContainer } from "./products-container";
export async function ProductsList({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
const { products, page } = await searchProducts(searchParams); // API 呼び出し
return <ProductsContainer products={products} page={page} />;
}
ProductsContainer で取得したデータを Context にセット
// features/products/components/ProductsContainer.tsx
"use client";
import { useEffect } from "react";
import { useProductsContext } from "@/features/products/providers/products-provider";
export function ProductsContainer({
products,
page,
}: {
products: Product[];
page: Page;
}) {
const { setProducts } = useProductsContext();
useEffect(() => {
setProducts(page); // Context にデータをセット
}, [page, setProducts]);
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
ProductsFilter で Context からデータを取得してページネーション情報を表示する
// features/products/components/products-filter.tsx
"use client";
import { useProductsContext } from "@/features/products/providers/products-provider";
export function ProductsFilter() {
const { page } = useProductsContext(); // Context からデータを取得
return (
<div>
<SearchFilter />
<Pagination page={page} />
</div>
);
}
Note: SearchFilter と Pagination では、
router.push()
を使用してクエリパラメータを更新し、ページの再レンダリングを行っています。
課題
この実装には以下の問題がありました
- データフローが複雑(サーバー → クライアント → Context → 別のクライアント)
- 本来は Collection で一度フェッチして props で渡したいが、Suspense の要件を満たすために迂回ルートが必要
- サーバーコンポーネントの利点を活かしきれていない
- コードの見通しが悪い
解決策1:Promise の共有と use API
React の use API を活用し、Promise を共有する方法:
- Collection でフェッチするが、await せず Promise のまま保持
- 同じ Promise を Filter と List の両方に渡す
- 各コンポーネントで use() を使って Promise を解決
- List のみ Suspense でラップ
コード例
Collection コンポーネントでサーバーからデータフェッチするが、await せず Promise 状態で保持
// app/_components/products-collection.tsx
import { Suspense } from "react";
import { Loading } from "@/components/ui/loading";
import { ProductsFilter } from "@/features/products/components/products-filter";
import { ProductsList } from "@/features/products/components/products-list";
import { searchProducts } from "@/features/products/api/search-products";
export function ProductsCollection({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
// await せずに Promise のまま保持
const productsPromise = searchProducts(searchParams);
return (
<div>
<ProductsFilter productsPromise={productsPromise} />
<Suspense fallback={<Loading />}>
<ProductsList productsPromise={productsPromise} />
</Suspense>
</div>
);
}
ProductsList で use API を使って Promise を解決し、商品一覧を表示
// features/products/components/products-list.tsx
"use client";
import { use } from "react";
export function ProductsList({
productsPromise,
}: {
productsPromise: Promise<{ products: Product[]; page: Page }>;
}) {
const { products } = use(productsPromise); // Promise を解決
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
ProductsFilter で use API を使って Promise を解決し、ページネーション情報を表示
// features/products/components/products-filter.tsx
"use client";
import { use } from "react";
export function ProductsFilter({
productsPromise,
}: {
productsPromise: Promise<{ products: Product[]; page: Page }>;
}) {
const { page } = use(productsPromise); // Promise を解決
return (
<div>
<SearchFilter />
<Pagination page={page} />
</div>
);
}
メリット
- シンプルなデータフロー: Context によるデータ共有が不要で理解しやすい
- 明示的なデータ共有: 同じデータを使っていることがコードから明白
- 可読性の高さ: データフローが明確で理解しやすい
解決策2:Next.js の Request Memoization を活用した独立フェッチ
各コンポーネントで独立してフェッチし、Next.js の Request Memoization で重複を排除:
- List と Filter がそれぞれ独立して searchProducts() を呼ぶ
- Server Component で fetch API を使用すると、自動的に Request Memoization が適用され、実際の API リクエストは 1 回
- 両方ともサーバーコンポーネントとして実装
- List のみ Suspense でラップ
コード例
Collection コンポーネントで両方のコンポーネントを配置、それぞれが独立してデータフェッチを行う
// app/_components/products-collection.tsx
import { Suspense } from "react";
import { Loading } from "@/components/ui/loading";
import { ProductsFilter } from "@/features/products/components/products-filter";
import { ProductsList } from "@/features/products/components/products-list";
export function ProductsCollection({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
return (
<div>
<ProductsFilter searchParams={searchParams} />
<Suspense fallback={<Loading />}>
<ProductsList searchParams={searchParams} />
</Suspense>
</div>
);
}
ProductsList でサーバーからデータフェッチを行い、商品一覧を表示
// features/products/components/products-list.tsx
import { searchProducts } from "@/features/products/api/search-products";
export async function ProductsList({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
const { products } = await searchProducts(searchParams); // API 呼び出し
return (
<div>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
ProductsFilter でも同じ API を呼び出すが、Request Memoization により実際のリクエストは重複しない
// features/products/components/products-filter.tsx
import { searchProducts } from "@/features/products/api/search-products";
export async function ProductsFilter({
searchParams,
}: {
searchParams: Record<string, string>;
}) {
const { page } = await searchProducts(searchParams); // 同じ API を呼び出し
return (
<div>
<SearchFilter />
<Pagination page={page} />
</div>
);
}
メリット
- シンプルさ: 各コンポーネントが自分に必要なデータを宣言的にフェッチ
- 自動最適化: fetch の重複は自動的に排除される
- 保守性: コンポーネントが自己完結していて、依存関係が明確
- テスタビリティ: 各コンポーネントを独立してテストできる
- Next.js の思想に合致: サーバーファーストで推奨されるパターン
考察
両アプローチには一長一短があります
解決策 1 は、データの流れが明示的で理解しやすいです。同じ Promise を共有していることがコードから明確です。ただし、 Filter と List が Collection に依存する設計となります。
解決策 2 は、各コンポーネントが独立しており、再利用性が高いです。ただし、Request Memoization の仕組みを知らないと、同じ API を重複して呼んでいるように見える可能性があります。
結論
どちらのアプローチも最初の課題を解決できる方法です。どちらも Next.js のドキュメントで紹介されており、推奨されるパターンです。選択は、コンポーネント設計の考え方によって決まります。
プロジェクトの要件やチームの設計方針に合わせて選択するのが良いと思います。