Next.jsのチュートリアルをやってみる【ストリーミング編】

前回

Next.jsのチュートリアルをやってみる【レンダリング編】

はじめに

前回はNext.jsの様々なレンダリング方法について学びました。また、遅いデータ取得がアプリケーションのパフォーマンスにどのくらい影響するのかもシミュレートして確認しました。

今回はそう言った遅いデータ取得があった場合に、ストリーミングを使用してどのようにUXを改善できるかを学んでいきます。

ストリーミングとは?

ストリーミングとは、ルートをより小さい『チャンク』という単位に分割し、準備ができ次第サーバーからクライアントに順次配信するデータ転送技術です。

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

ストリーミングはReactのコンポーネントモデルと相性がよく、各コンポーネントをチャンクとみなします。

Next.jsでストリーミングを実装するは以下の方法があります。

  • ページレベルではloading.tsxファイルで実装。
  • 特定のコンポーネントについては<Suspense>を使用する。

ページ全体のストリーミング

ページ全体をストリーミングするにはloading.tsxを使用する必要があります。/app/dashboardフォルダにloading.tsxというファイルを作成して、以下のコードを追加しましょう。

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

サーバーを起動しているのを確認したら、http://localhost:3000/dashboard/にアクセスして、読み込み中は以下のような画面が表示されるのを確認しましょう。

作成したloading.tsxではこんなことをしています。

  1. loading.tsxSusppenseの上に構築された特別なNext.jsのファイルで、ページのコンテンツがロードされる間に代替として表示するフォールバックUIです。
  2. <SideNav>は静的なコンポーネントなのですぐに表示されます。その他の動的コンポーネントを読み込んでいる最中も<SideNav>を使用することができます。
  3. ユーザーはページの読み込みが終わるまでナビゲーションを待つ必要はありません。(これを中断可能なナビゲーションと呼びます)

これでストリーミングの実装は完了しました。続いて、「Loading…」というテキストではなく、ロード中を表すUIのスケルトンスクリーンを表示してみましょう。

スケルトンスクリーンの追加

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

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

/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よりも上位にあるため、この二つのページにも適用されます。

この設定はRoute Groupsで変更できます。dashboardフォルダ内に/(overview)という新しいフォルダを作成し、loading.tsxpage.tsxファイルをそのフォルダ内に移動させましょう。これでloadings.tsxファイルはダッシュボードの概要ページのみに適用されます。

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

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

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

ここまではページ全体をストリーミングしました。しかし、ReactのSuspenseを使用すればさらに細かく特定のコンテンツをストリーミングすることもできます。

Suspenseでは何らかの条件が満たされるまで、アプリケーションの一部のレンダリングを遅らせることができます。動的コンポーネントをSuspenseでラップすることができ、動的コンポーネントのロード中に表示するフォールバックコンポーネントを渡します。

以前fetchRevenue()で遅いデータリクエストを再現しました。Suspenseを使用してコンポーネントだけをストリーミングさせ、ページの残りのUIをすぐに表示させるようにしましょう。

まずはデータフェッチをコンポーネントに移動させます。続いてReactから<Suspense>をインポートして<RevenueChart />で囲み、<RevenueChartSkelton>というフォールバック・コンポーネントを渡します。

/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>コンポーネントが自身のデータを取得して渡されたpropを削除するように変更します。

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

サーバーが起動されていることを確認して画面を更新すると、以下の様にチャートコンポーネントだけがスケルトンになっていることが確認できます。

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

初めにページから不要になるfetchlatestInvoicesを削除して<LatestInvoicesSkelton>コンポーネントを使用できるようにインポートします。その後、<LatestInvoices><Suspense>で囲み、フォールバックに<LatestInvoicesSkelton>コンポーネントを渡します。

/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';
import { Suspense } from 'react'
import { RevenueChartSkeleton, LatestInvoicesSkeleton } 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"
        />
      </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>
  );
}

続いて<LatestInvoice>コンポーネント側の変更をします。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() {
  const latestInvoices = await fetchLatestInvoices();
 
  return (
    // ...
  );
}

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

ここまでで<RevenueChart>コンポーネントと<LatestIvoices>コンポーネントを個別でストリーミングするように変更しました。

続いて、<Card>コンポーネントをストリーミングするように変更します。<Card>コンポーネントそれぞれに<Suspense>でラップすることで個別にデータを取得することもできますが、カードが読み込まれる際に飛び出すようなエフェクトが発生し、ユーザーにとって視覚的に不快なものとなってしまいます。

ラッパーコンポーネントを使用してカードをグループ化することでこの問題に対処することができます。つまり、最初に静的な<SideNav>が表示され、その後にカードなどが表示されます。

page.tsxファイルを以下のように変更しましょう。

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

続いて<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>でラップする方法が一般的です。

まとめ

  • ストリーミングを実装するには以下の方法を使用する。
    • ページレベルではloading.tsxファイルで実装。
    • コンポーネントごとにストリーミングするには<Suspense>を使用する。
  • loading.tsxファイルに追加されたコンポーネントは、静的ファイルとして一番最初に読み込まれる。
  • ストリーミングはスケルトンスクリーンを追加するのに使用できる。
  • データの取得を必要なコンポーネントに移して<Suspense>でラップする方法が一般的です。

次回

Next.jsのチュートリアルをやってみる【部分プリレンダリング編】

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です