Next.js App Router でのデータフェッチパターン:複数コンポーネントでのデータ共有と Suspense の使い分け

Next.js App Router で検索結果ページを実装する際の課題と解決策。Promise 共有と cache を活用した 2 つのアプローチで、複数コンポーネント間でのデータ共有とローディング状態の制御を実現する方法を比較検討します。

aonoSeptember 16, 2025

背景

Next.js App Router で、以下のような検索結果を表示するページを実装していました。

イメージ図

構成は以下の通り。

  • Filter コンポーネント:検索フィルターとページネーション
  • List コンポーネント:検索結果の表示

要件として、ページネーションに検索結果の総数が必要なため、両コンポーネントで同じ API レスポンスを共有する必要がありました。さらに、UX の観点から以下の制約がありました:

  • List:データ取得中はローディング表示
  • Filter:データ取得を待つが、ローディング表示はしない(フィルター UI が毎回消えるのは不自然なため)

最初のアプローチ

サーバーコンポーネントと Context を組み合わせた実装を行いました

  1. List(サーバーコンポーネント)でデータフェッチ
  2. Container(クライアントコンポーネント)で Context にデータを格納
  3. 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 を共有する方法:

  1. Collection でフェッチするが、await せず Promise のまま保持
  2. 同じ Promise を Filter と List の両方に渡す
  3. 各コンポーネントで use() を使って Promise を解決
  4. 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 で重複を排除:

  1. List と Filter がそれぞれ独立して searchProducts() を呼ぶ
  2. Server Component で fetch API を使用すると、自動的に Request Memoization が適用され、実際の API リクエストは 1 回
  3. 両方ともサーバーコンポーネントとして実装
  4. 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 のドキュメントで紹介されており、推奨されるパターンです。選択は、コンポーネント設計の考え方によって決まります。

プロジェクトの要件やチームの設計方針に合わせて選択するのが良いと思います。