コンテンツにスキップ

9

ストリーミング

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

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

取り上げるトピックは以下の通りです。

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

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

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

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

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

ストリーミングとは?

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

Diagram showing time with sequential data fetching and parallel data fetching

ストリーミングにより、遅いデータ取得がページ全体をブロックするのを防ぐことができます。これにより、ユーザーは UI が表示される前にすべてのデータがロードされるのを待つことなく、ページの各部分を表示したり操作したりできます。

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 よりも 1 レベル上にあるため、これらのページにも適用されます。

これを ルートグループ で変更できます。ダッシュボードフォルダ内に /(overview) という新しいフォルダを作成します。次に、loading.tsx および page.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

ストリーミングを考慮して構築された新しい実験的なレンダリングモデルである Partial Prerendering の初期プレビュー。