コンテンツへスキップ

9

ストリーミング

前章では、Next.js のさまざまなレンダリング方法について学びました。また、データ取得が遅いとアプリケーションのパフォーマンスにどのように影響するかも説明しました。データ要求が遅い場合にユーザーエクスペリエンスを向上させる方法を見ていきましょう。

このチャプターでは...

取り上げるトピックは次のとおりです

ストリーミングとは何か、そしていつ使用するか。

loading.tsx と Suspense を使用してストリーミングを実装する方法。

ローディングスケルトンとは何か。

Next.js のルーティンググループとは何か、そしていつ使用するか。

アプリケーション内で React Suspense の境界をどこに配置するか。

ストリーミングとは?

ストリーミングとは、ルートをより小さな「チャンク」に分割し、準備ができたものから順にサーバーからクライアントへ段階的にストリームできるデータ転送技術です。

Diagram showing time with sequential data fetching and parallel data fetching

ストリーミングを使用することで、遅いデータ要求がページ全体をブロックするのを防ぐことができます。これにより、すべてのデータが読み込まれるのを待たずに、ユーザーはページの一部を表示し、操作できるようになります。

Diagram showing time with sequential data fetching and parallel data fetching

ストリーミングは React のコンポーネントモデルとうまく機能します。各コンポーネントをチャンクとみなすことができるためです。

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

  1. ページレベルでは、loading.tsx ファイルを使用します(これは自動的に<Suspense>を作成します)。
  2. コンポーネントレベルでは、よりきめ細かな制御のために<Suspense>を使用します。

それでは、どのように機能するか見ていきましょう。

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

/app/dashboard フォルダに、loading.tsx という新しいファイルを作成します。

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

https://:3000/dashboard をリフレッシュすると、次のように表示されるはずです

Dashboard page with 'Loading...' text

ここではいくつかのことが起こっています

  1. loading.tsx は、React Suspense の上に構築された特別な Next.js ファイルです。ページのコンテンツが読み込まれている間に、代替として表示するフォールバック UI を作成できます。
  2. <SideNav> は静的なので、すぐに表示されます。ユーザーは動的なコンテンツが読み込まれている間でも<SideNav> を操作できます。
  3. ユーザーは、ページが完全に読み込まれるのを待たずに別のページに移動できます(これは中断可能なナビゲーションと呼ばれます)。

おめでとうございます!ストリーミングを実装できました。しかし、ユーザーエクスペリエンスを向上させるために、さらにできることがあります。Loading… テキストの代わりにローディングスケルトンを表示してみましょう。

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

ローディングスケルトンは、UI の簡略化されたバージョンです。多くのウェブサイトでは、コンテンツが読み込み中であることをユーザーに示すためのプレースホルダー(またはフォールバック)として使用されています。loading.tsx に追加した UI は静的ファイルの一部として埋め込まれ、最初に送信されます。その後、残りの動的コンテンツがサーバーからクライアントにストリーミングされます。

loading.tsx ファイル内で、<DashboardSkeleton> という新しいコンポーネントをインポートします。

/app/dashboard/loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

次に、https://:3000/dashboard をリフレッシュすると、次のように表示されるはずです

Dashboard page with loading skeletons

ルートグループでローディングスケルトンのバグを修正する

現在、ローディングスケルトンは請求書に適用されます。

loading.tsx はファイルシステムで /invoices/page.tsx/customers/page.tsx よりも上位のレベルにあるため、それらのページにも適用されます。

これは ルーティンググループ で変更できます。ダッシュボードフォルダ内に /(overview) という新しいフォルダを作成します。次に、loading.tsxpage.tsx ファイルをそのフォルダの中に移動します。

Folder structure showing how to create a route group using parentheses

これで、loading.tsx ファイルはダッシュボードの概要ページにのみ適用されるようになります。

ルーティンググループを使用すると、URL パス構造に影響を与えることなく、ファイルを論理的なグループに整理できます。括弧 () を使用して新しいフォルダを作成すると、その名前は URL パスに含まれません。したがって、/dashboard/(overview)/page.tsx/dashboard になります。

ここでは、ルーティンググループを使用して loading.tsx がダッシュボードの概要ページにのみ適用されるようにしています。ただし、ルーティンググループは、アプリケーションをセクション(例:(marketing) ルートと (shop) ルート)に分割したり、大規模なアプリケーションではチームごとに分割したりするためにも使用できます。

コンポーネントのストリーミング

これまではページ全体をストリーミングしていましたが、React Suspense を使用して、より細かく特定のコンポーネントをストリーミングすることもできます。

Suspense を使用すると、特定の条件が満たされるまで(例:データが読み込まれるまで)アプリケーションの一部をレンダリングを遅延させることができます。動的なコンポーネントを Suspense でラップし、動的なコンポーネントが読み込まれている間に表示するフォールバックコンポーネントを渡すことができます。

遅いデータ要求である fetchRevenue() を覚えているでしょうか。これはページ全体を遅くしている要求です。ページ全体をブロックする代わりに、Suspense を使用してこのコンポーネントのみをストリーミングし、ページの残りの UI をすぐに表示することができます。

そのためには、データ取得をコンポーネントに移動する必要があります。どのように見えるか、コードを更新してみましょう。

/dashboard/(overview)/page.tsx から fetchRevenue() のすべてのインスタンスとそのデータを削除します。

/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'; // remove fetchRevenue
 
export default async function Page() {
  const revenue = await fetchRevenue() // delete this line
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    // ...
  );
}

次に、React から <Suspense> をインポートし、<RevenueChart /> を囲みます。これに <RevenueChartSkeleton> というフォールバックコンポーネントを渡すことができます。

/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>
  );
}

最後に、<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() { // Make component async, remove the props
  const revenue = await fetchRevenue(); // Fetch data inside the component
 
  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> のフォールバックスケルトンが表示されるはずです。

Dashboard page with revenue chart skeleton and loaded Card and Latest Invoices components

演習: <LatestInvoices> のストリーミング

今度はあなたの番です!学んだことを練習して、<LatestInvoices> コンポーネントをストリーミングしてみましょう。

fetchLatestInvoices() をページから <LatestInvoices> コンポーネントに移動します。コンポーネントを <Suspense> の境界で囲み、<LatestInvoicesSkeleton> というフォールバックを渡します。

準備ができたら、トグルを展開して解答コードを表示してください

コンポーネントのグループ化

素晴らしい!あと少しです。次に、<Card> コンポーネントを Suspense でラップする必要があります。個々のカードのデータを取得することはできますが、カードが読み込まれる際にポップアップするような効果が発生し、ユーザーにとって視覚的に不快になる可能性があります。

では、この問題をどう解決しますか?

よりずらした効果を作成するには、ラッパーコンポーネントを使用してカードをグループ化できます。これにより、静的な <SideNav/> が最初に表示され、次にカードなどが表示されるようになります。

page.tsx ファイルで

  1. <Card> コンポーネントを削除します。
  2. fetchCardData() 関数を削除します。
  3. <CardWrapper /> という新しいラッパーコンポーネントをインポートします。
  4. <CardsSkeleton /> という新しいスケルトンコンポーネントをインポートします。
  5. <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 ファイルに移動し、fetchCardData() 関数をインポートし、<CardWrapper/> コンポーネント内で呼び出します。このコンポーネント内で必要なコードのコメントを解除するようにしてください。

/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 の境界をどこに配置するかを決定する

Suspense の境界をどこに配置するかは、いくつかの要素に依存します。

  1. ストリーミング中にユーザーにページをどのように体験してほしいか。
  2. どのコンテンツを優先したいか。
  3. コンポーネントがデータ取得に依存しているか。

ダッシュボードページを見て、何か違うことをしたでしょうか?

心配いりません。正解はありません。

  • loading.tsx を使用したようにページ全体をストリーミングすることもできますが、コンポーネントのいずれかがデータ取得に時間がかかると、読み込み時間が長くなる可能性があります。
  • すべてのコンポーネントを個別にストリーミングすることもできますが、準備ができたときに UI が画面にポップアップ表示される可能性があります。
  • ページセクションをストリーミングすることで、ずらした効果を作成することもできます。しかし、そのためにはラッパーコンポーネントを作成する必要があります。

Suspense の境界をどこに配置するかは、アプリケーションによって異なります。一般的には、データ取得を必要とするコンポーネントに移動し、それらのコンポーネントを Suspense でラップするのが良い習慣です。しかし、アプリケーションが必要とするものであれば、セクション全体やページ全体をストリーミングしても問題ありません。

Suspense を試して何が最適かを見つけることを恐れないでください。これは、より魅力的なユーザーエクスペリエンスを作成するのに役立つ強力な API です。

今後の展望

ストリーミングとサーバーコンポーネントは、データ取得と読み込み状態を処理する新しい方法を提供し、最終的にエンドユーザーエクスペリエンスの向上を目指します。

次の章では、ストリーミングを念頭に置いて構築された新しい Next.js レンダリングモデルである Partial Prerendering について学びます。

チャプターを完了しました9

Suspense とローディングスケルトンでコンポーネントをストリーミングする方法を学びました。

次へ

10: 部分的なプリレンダリング

Partial Prerendering の先行公開 - ストリーミングを考慮して構築された新しい実験的なレンダリングモデル。