ストリーミング

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

ストリーミングとは?

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

逐次的なデータ取得と並列的なデータ取得を示す時間図

ストリーミングにより、遅いデータリクエストがページ全体をブロックするのを防ぐことができます。これにより、ユーザーはすべてのデータが読み込まれる前にページの一部を表示・操作できます。

逐次的なデータ取得と並列的なデータ取得を示す時間図

ストリーミングは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>;
}

http://localhost:3000/dashboardを更新すると、次のように表示されます:

'Loading...'テキストが表示されたダッシュボードページ

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

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

http://localhost:3000/dashboardを更新すると、次のように表示されます:

ローディングスケルトンが表示されたダッシュボードページ

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

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

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

ルートグループを使用してこれを変更できます。ダッシュボードフォルダ内に/(overview)という新しいフォルダを作成し、loading.tsxpage.tsxファイルをその中に移動させます:

括弧を使用してルートグループを作成するフォルダ構造

これで、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'; // fetchRevenueを削除
 
export default async function Page() {
  const revenue = await fetchRevenue() // この行を削除
  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() { // コンポーネントをasyncに、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>にはフォールバックスケルトンが表示されます:

収益チャートのスケルトンと読み込まれたカード、最新請求書コンポーネントが表示されたダッシュボードページ

練習:<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で行ったようにページ全体をストリーミングすることもできます...しかし、コンポーネントの1つに遅いデータ取得がある場合、読み込み時間が長くなる可能性があります
  • 各コンポーネントを個別にストリーミングすることもできます...しかし、準備が整ったUIが画面に_ポップ_インする可能性があります
  • ページセクションをストリーミングして_段階的_な効果を作成することもできます。ただし、ラッパーコンポーネントを作成する必要があります

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

Suspenseを試して最適な方法を見つけることを恐れないでください。より楽しいユーザー体験を作成するのに役立つ強力なAPIです。

次へ進む

ストリーミングとサーバーコンポーネントは、データ取得とローディング状態を処理する新しい方法を提供し、最終的にはエンドユーザー体験を向上させることを目的としています。

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