コンテンツにスキップ

Chapter 9: ストリーミング

前章でウォーターフォールを避ける方法を学びましたが,それでも一部のデータ取得はどうしても時間がかかる場合があります。この章では React Suspense と App Router の仕組みを活用し,遅い処理があっても画面を素早く表示するストリーミング戦略を学びます。


この章で学ぶこと

  • ストリーミングとは何か,いつ使うのか
  • loading.tsxReact Suspense を使ったストリーミングの実装方法
  • ローディングスケルトンとは何か
  • Route Groups(ルートグループ)とは何か,いつ使うのか
  • アプリ内で Suspense境界 をどこに置くべきか

ストリーミングとは?

ストリーミングは,1つのルートを小さな「チャンク」に分割し,準備できた順にサーバーからクライアントへ段階的に送るデータ転送手法です。

alt text

これにより,遅いデータがページ全体の表示をブロックするのを防ぎ,ユーザーは全データが揃う前でもページの一部を見たり操作したりできます。

alt text

React のコンポーネントモデルと相性がよく,各コンポーネント=チャンクとして扱えます。

Next.js でストリーミングを実装する方法は2通りあります。

  1. ページ単位loading.tsx(Next.js が <Suspense> を自動で生成)
  2. コンポーネント単位:自分で <Suspense> を配置(より細かい制御)

loading.tsx でページ全体をストリーミング

/app/dashboard フォルダ内に loading.tsx を作成します。

/app/dashboard/loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}

http://localhost:3000/dashboard をリロードすると,「Loading…」が表示されます。

alt text

ここで起きていること:

  • loading.tsxReact Suspense を土台にした Next.js の特別なファイルで,ページ内容の読み込み中に表示する フォールバックUI を定義できます。
  • <SideNav> は静的なので 即時表示 され,動的コンテンツの読み込み中でも操作できます。
  • ページのロード完了を待たずに 他ページへ遷移できる(中断可能ナビゲーション) ようになります。

テキストだけでなく,ローディングスケルトンを表示してより良い体験にしましょう。


ローディングスケルトンの追加

ローディングスケルトンは,本来のUIを簡略化したプレースホルダです。 loading.tsx に書いたUIは 静的ファイルに埋め込まれて先に配信され,その後にサーバーから動的コンテンツがストリーミングされます。

/app/dashboard/loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';

export default function Loading() {
  return <DashboardSkeleton />;
}

リロードすると,ダッシュボードのスケルトンが表示されます。

alt text


Route Groups でスケルトンの適用範囲を修正

現在の loading.tsx/dashboard 配下全体(/invoices/customers など)にも適用されます。 これを ダッシュボードの概要ページのみに限定するため,Route Group を使います。

/dashboard 内に /(overview) フォルダを作成し,loading.tsxpage.tsx をその中へ移動します。

alt text

Route Group は,URL には現れない括弧付きフォルダです。 例えば /dashboard/(overview)/page.tsx のURLは依然として /dashboard のままです。 これで,loading.tsx概要ページのみに適用されます。

Route Group は,マーケ用 (marketing) とショップ用 (shop) のように,URLを変えずに論理的にファイルを分離したいときにも便利です。


コンポーネントをストリーミングする

ページ全体のストリーミングだけでなく,特定コンポーネント単位でのストリーミングも可能です。 Suspense は「ある条件(例:データの取得)が満たされるまでレンダリングを遅らせる」仕組みです。動的コンポーネントを <Suspense> で包み,読み込み中に表示するフォールバックを渡します。

前章の遅いリクエスト fetchRevenue() がページ全体を遅くしていました。 ページ全体をブロックせず,そのコンポーネントだけストリーミングするようにしましょう。

1) ページから fetchRevenue() を取り除く

/app/dashboard/(overview)/page.tsx
// 変更前(抜粋)
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // fetchRevenue は削除

export default async function Page() {
  const revenue = await fetchRevenue() // ← この行を削除
  const latestInvoices = await fetchLatestInvoices();
  // ...
}

2) <RevenueChart /><Suspense> で包む

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';

export default async function Page() {
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();

  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

3) <RevenueChart> 側で自分のデータを取得

/app/ui/dashboard/revenue-chart.tsx
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';

// ...

export default async function RevenueChart() { // 非同期化し,props を削除
  const revenue = await fetchRevenue(); // コンポーネント内で取得

  const chartHeight = 350;
  const { yAxisLabels, topLabel } = generateYAxis(revenue);

  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">No data available.</p>;
  }

  return (
    // ...
  );
}

これでページを更新すると,カードや最新請求はすぐ表示され,<RevenueChart>スケルトンを出しつつ後追いでロードされます。

alt text


<LatestInvoices> をストリーミング

  • fetchLatestInvoices() をページから <LatestInvoices> コンポーネント内に移動します。
  • <LatestInvoices><Suspense> で包み,フォールバックに <LatestInvoicesSkeleton> を渡します。

ダッシュボードページ(抜粋)

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchCardData } from '@/app/lib/data'; // fetchLatestInvoices は削除
import { Suspense } from 'react';
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
} from '@/app/ui/skeletons';

export default async function Page() {
  // const latestInvoices = await fetchLatestInvoices() は削除
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();

  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <Suspense fallback={<LatestInvoicesSkeleton />}>
          <LatestInvoices />
        </Suspense>
      </div>
    </main>
  );
}

<LatestInvoices> コンポーネント

/app/ui/dashboard/latest-invoices.tsx
import { ArrowPathIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Image from 'next/image';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices } from '@/app/lib/data';

export default async function LatestInvoices() { // props を削除
  const latestInvoices = await fetchLatestInvoices();

  return (
    // ...
  );
}

複数カードの読み込み順を整える(グルーピング)

<Card> をそれぞれ個別に Suspense で包むと,バラバラにポップインする「はね」感が出ることがあります。 これを避けたい場合,カード群をラッパーコンポーネントでまとめてストリーミングしましょう。 (例:<SideNav> → カード群 → その後に他セクション…のような段階的表示

ページ側の変更

  • 既存の <Card> 群を削除
  • fetchCardData() 呼び出しを削除
  • ラッパーの <CardWrapper /><CardsSkeleton /> をインポート
  • <CardWrapper /> を Suspense で包む
/app/dashboard/(overview)/page.tsx
import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton,
} from '@/app/ui/skeletons';

export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<CardsSkeleton />}>
          <CardWrapper />
        </Suspense>
      </div>
      {/* ほかのセクション… */}
    </main>
  );
}

ラッパー側でまとめて取得

/app/ui/dashboard/cards.tsx
// ...
import { fetchCardData } from '@/app/lib/data';

// ...

export default async function CardWrapper() {
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();

  return (
    <>
      <Card title="Collected" value={totalPaidInvoices} type="collected" />
      <Card title="Pending" value={totalPendingInvoices} type="pending" />
      <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
      <Card
        title="Total Customers"
        value={numberOfCustomers}
        type="customers"
      />
    </>
  );
}

リロードすると,カードが同時にふわっと表示されるはずです。複数コンポーネントを同時に見せたいときに有効なパターンです。


Suspense 境界をどこに置くか?

配置はアプリの性質や意図する体験に依存します。

  • ページ全体loading.tsx で包む → 実装は簡単だが,どれか1つが遅いと全体が待ちになりがち
  • すべてのコンポーネントを個別に包む → ポップインが多発して視覚的に落ち着かない場合あり
  • セクションごとに包む → ほどよい分割だが,ラッパーの設計が必要

一般的には,データ取得を「必要なコンポーネントの中」へ寄せ,そのコンポーネントを Suspense で包むのが扱いやすいです。 ただし,アプリの要件によってはセクション単位やページ単位のストリーミングでも問題ありません。 試して最適解を見つけることを恐れずに,Suspense を活用してみてください。


この先へ

ストリーミングと Server Components により,データ取得とローディング状態を柔軟に扱えます。 次章では,ストリーミングを前提に設計された新しいレンダリングモデル Partial Prerendering を学びます。