🎥

ゼロから学ぶ React, Next.js⑯【Learn Next.js】Chapter9

2024/05/25に公開

ストリーミング

前の章では、ダッシュボードページを動的にしましたが、遅いデータフェッチがアプリケーションのパフォーマンスに影響を与える可能性があることを説明しました。遅いデータリクエストがある場合のユーザーエクスペリエンスを改善する方法を見てみましょう。

この章で扱うトピック

  • 🗄️ ストリーミングとは何か、いつ使用するか
  • 💭 loading.tsxSuspenseを使用してストリーミングを実装する方法
  • 📄 ローディングスケルトンとは何か
  • 🛜 ルートグループとは何か、いつ使用するか
  • 🕓 アプリケーションでサスペンス境界をどこに配置するか

ストリーミングとは?

ストリーミングは、ルートをより小さな「チャンク」に分割し、準備ができ次第、サーバーからクライアントに徐々にストリーミングできるデータ転送手法です。

順次データフェッチと並列データフェッチの時間を示す図

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

順次データフェッチと並列データフェッチの時間を示す図

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

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

  1. ページレベルでは、loading.tsxファイルを使用する
  2. 特定のコンポーネントでは、<Suspense>を使用する

これがどのように機能するかを見てみましょう。

クイズの時間です!
知識をテストし、学んだことを確認しましょう。

ストリーミングの利点の1つは何ですか?

A. チャンクの暗号化によってデータリクエストがより安全になる
B. すべてのチャンクは、完全に受信された後にのみレンダリングされる
C. チャンクが並行してレンダリングされ、全体の読み込み時間が短縮される

解答

C. チャンクが並行してレンダリングされ、全体の読み込み時間が短縮される
この方法の利点のひとつは、ページ全体の読み込み時間を大幅に短縮できることです。


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

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

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

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

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

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

  • loading.tsxは、Suspenseの上に構築された特別なNext.jsファイルであり、ページコンテンツの読み込み中に代替として表示するフォールバックUIを作成できます。
  • <SideNav>は静的なので、すぐに表示されます。ユーザーは、動的コンテンツの読み込み中に<SideNav>を操作できます。
  • ユーザーは、ページの読み込みが完了するのを待ってから移動する必要はありません(これは割り込み可能なナビゲーションと呼ばれます)。

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

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

ローディングスケルトンは、UIの簡略版です。多くのWebサイトでは、コンテンツの読み込み中であることをユーザーに示すためのプレースホルダー(またはフォールバック)として使用します。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よりも1つ上のレベルにあるため、それらのページにも適用されます。

これは、ルートグループを使用して変更できます。dashboardフォルダの中に、()を使用して(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 { fetchRevenue, fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
+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() { // コンポーネントを非同期にし、プロップを削除
+  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>コンポーネントに移動します。<LatestInvoicesSkeleton>というフォールバックを使用して、コンポーネントを<Suspense>境界でラップします。

準備ができたら、トグルを展開してソリューションコードを確認してください:

解答

Dashboardページ:

/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'; // Remove fetchLatestInvoices
 import { Suspense } from 'react';
 import {
   RevenueChartSkeleton,
+  LatestInvoicesSkeleton,
 } from '@/app/ui/skeletons';
 
 export default async function Page() {
   // Remove `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>コンポーネント propsを削除するのを忘れずに!:

/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/>が表示され、次にカードなどが表示されることを意味します。

page.tsxファイルで:

  1. <Card>コンポーネントを削除します。
  2. fetchCardData()関数を削除します。
  3. <CardWrapper />という新しいラッパーコンポーネントをインポートします。
  4. <CardsSkeleton />という新しいスケルトンコンポーネントをインポートします。
  5. <CardWrapper />Suspenseでラップします。
/app/dashboard/page.tsx
+import CardWrapper from '@/app/ui/dashboard/cards';
 // ...
 import {
   RevenueChartSkeleton,
   LatestInvoicesSkeleton,
+  CardsSkeleton,
 } from '@/app/ui/skeletons';
 
 export default async function Page() {
- const {
-   numberOfCustomers,
-   numberOfInvoices,
-   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"
-        />
+        <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"
+     />
    </>
  );
}

ページを更新すると、すべてのカードが同時に読み込まれるはずです。複数のコンポーネントを同時に読み込みたい場合は、このパターンを使用できます。


サスペンス境界の配置場所を決定する

サスペンス境界の配置場所は、いくつかの要因に依存します:

  • ページがストリーミングされる際に、ユーザーにどのようにページを体験してほしいか。
  • 優先したいコンテンツ。
  • コンポーネントがデータフェッチに依存しているかどうか。

ダッシュボードページを見てみてください。何か違うことをしたでしょうか?

心配しないでください。正解はありません。

  • loading.tsxを使用してページ全体をストリーミングできます...ただし、コンポーネントの1つにデータフェッチが遅いものがある場合、読み込み時間が長くなる可能性があります。
  • すべてのコンポーネントを個別にストリーミングできます...ただし、準備ができ次第、UIが画面にポップインする可能性があります。
  • ページセクションをストリーミングすることで、段階的な効果を作成することもできます。ただし、ラッパーコンポーネントを作成する必要があります。

サスペンス境界の配置場所は、アプリケーションによって異なります。一般的に、データフェッチを必要とするコンポーネントに移動し、それらのコンポーネントをSuspenseでラップすることをお勧めします。ただし、アプリケーションに必要な場合は、セクションまたはページ全体をストリーミングしても問題ありません。

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

クイズの時間です!
知識をテストし、学んだことを確認しましょう。

一般的に、Suspenseとデータフェッチを使用する際のベストプラクティスは何ですか?

A. データフェッチを親コンポーネントに移動する
B. データフェッチにはSuspenseを使用しない
C. データフェッチを必要とするコンポーネントに移動する
D. エラー境界にのみSuspenseを使用する

解答

C. データフェッチを必要とするコンポーネントに移動する
データ取得を必要なコンポーネントに移すことで、より細かいSuspension境界を作ることができます。これにより、特定のコンポーネントをストリーミングし、UIがブロックされるのを防ぐことができます。


今後の展望

ストリーミングとサーバーコンポーネントにより、データフェッチと読み込み状態を処理する新しい方法が提供され、最終的にエンドユーザーエクスペリエンスが向上します。

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


次の章

https://zenn.dev/gunjo/articles/8d9ea5c17e45fe

Discussion