⚡️

App Routerにおけるリクエストウォーターフォールとストリーミング

2024/01/11に公開

App Router を勉強したく Next.js のチュートリアルを進めていると「リクエストウォーターフォール」と「ストリーミング」の実装まで丁寧に教えてくれていました。

復習と知識定着のために自分でもう一度まとめて記事に残しておくことにします。

ソースは Next.js がチュートリアルのために用意してくれているものを利用しています。

参考までに自分が使ったコードとチュートリアルを置いておきます。

https://github.com/ysknsid25/nextjs-dashboard

https://nextjs.org/learn/dashboard-app

リクエストウォーターフォールとは

前のリクエストの完了に依存する一連のネットワークリクエストのことです。

例えば以下のコードの場合、fetchRevenueの実行後にfetchLatestInvoicesが実行されることになります。

全てのリクエストが解決するまでデータの表示がブロックされることになるため、ユーザーはローディング画面で待機することになります。

const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // wait for fetchRevenue() to finish
const {
  numberOfInvoices,
  numberOfCustomers,
  totalPaidInvoices,
  totalPendingInvoices,
} = await fetchCardData(); // wait for fetchLatestInvoices() to finish

Next.js公式ドキュメントより

ただしウォーターフォールが必ずしも悪いことかというとそうではないです。

前の fetch によって何かしら ID を取得し、その ID をもって外部キーに検索を引っ掛けたい場合があります。

例えば何らかの条件を元にユーザーの ID を取得し、そのユーザーの購入履歴を取得する場合などが考えられます。

並列データフェッチ

ウォーターフォールを回避する一般的な方法はすべてのデータ要求を並行して開始することです。

このための方法としてはPromise.all()などがあります。

ただし、並列データフェッチが必ずしもリクエストウォーターフォールによるパフォーマンス低下を解決するかというと、否です。

並列データフェッチによる最大レスポンス時間というのは、最もレスポンスが遅い fecth になります。

例えば 3 つのリクエストを同時にこなし、それぞれレスポンスが 0.1 秒、0.2 秒、10 秒だった場合、ウォーターフォールの場合と比べて短縮できるレスポンス時間は 0.3 秒です。

確かに多少は改善されていますが、劇的な改善とまではいきません。

ストリーミング

ここまで述べた問題を解決するのがストリーミングという技術です。

ストリーミングではルートコンポーネントをより小さなコンポーネントであるチャンクに分割し、準備が整ったチャンクからレスポンスを開始します。

そのため、遅いデータ要求によってページ全体がブロックされるのを防ぐことが可能です。

これにより、ユーザーは全てのデータ取得が完了するまでローディング画面で待機するのではなく、レスポンスが完了したコンポーネントから順に操作することが可能になります。

Next.js でストリーミングを実装する方法は以下の二つです。

  1. ページレベルでloading.tsxを利用する
  2. 特定のコンポーネントごとに<Suspense>を利用する

後ほどそれぞれのパターンを以下のようなページに対して適用していきます。

以下はとあるアプリのダッシュボードです。

左赤枠部分のサイドバーは静的、右赤枠部分は動的なデータを含みます。

現状はリクエストウォーターフォールが発生している状態のため、このダッシュボードを表示する際には全てのリクエストが解決するまで画面全体が見えない状態になっています。

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

このダッシュボードを表示するためのpage.tsxがある階層と同じ階層に以下の内容でloading.tsxを追加します。

loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';

export default function Loading() {
    return <DashboardSkeleton />;
}

この状態でリロードすると、メニュー部分が表示された状態でローディングが走っていることがわかります。

ここで追加したloading.tsxは Suspense 上で構築された特別な Next.js ファイルで、ページの読み込み中に代替として表示する UI を提供することができます。

ただし、loading.tsxは配置ディレクトリ以下の全てのpage.tsxに対して適用されてしまいます。

この場合は論理グループという機能を利用します。

論理グループとはカッコ()を利用して作成されたフォルダのことで、その名前は URL パスに含まれないため、その中へloading.tsxpage.tsxを移動させることで影響範囲を縮めることができます。

特定のコンポーネントごとに<Suspense>を利用する

現状、データ fetch はpage.tsx内部で一括で行っています。

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';

export default async function Page() {
    const revenue = await fetchRevenue();
    const latestInvoices = await fetchLatestInvoices();
    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">
                <RevenueChart revenue={revenue} />
                <LatestInvoices latestInvoices={latestInvoices} />
            </div>
        </main>
    );
}

これをSuspenseを利用し、データ取得処理を各コンポーネントに委譲します。

fallbackには Suspense が Resolve されるまでの間表示するコンポーネントを渡します。

TypeScript
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';
+ import { Suspense } from 'react';
+ import { RevenueChartSkeleton } from '@/app/ui/skeletons';

export default async function Page() {
-    const revenue = await fetchRevenue();
    const latestInvoices = await fetchLatestInvoices();
    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">
-                <RevenueChart revenue={revenue} />
+                <Suspense fallback={<RevenueChartSkeleton />}>
+                    <RevenueChart />
+                </Suspense>
                <LatestInvoices latestInvoices={latestInvoices} />
            </div>
        </main>
    );
}

この状態でページを更新するとすぐにダッシュボードが表示され、最も取得に時間がかかっている部分だけがスケルトン表示されています。

おわりに

Suspenseがかなり強力な API だということがよくわかりました。

ただ途中で述べたように、必ずしもリクエストウォータフォールが劣っていて、Suspenseを使うパターンが優れているというわけではありません。

Next.js もドキュメントでこのように述べています。

Deciding where to place your Suspense boundaries
Where you place your Suspense boundaries will depend on a few things:

  1. How you want the user to experience the page as it streams.
  2. What content you want to prioritize.
  3. If the components rely on data fetching.

Take a look at your dashboard page, is there anything you would've done differently?

Don't worry. There isn't a right answer.

  • You could stream the whole page like we did with loading.tsx... but that may lead to a longer loading time if one of the components has a slow data fetch.
  • You could stream every component individually... but that may lead to UI popping into the screen as it becomes ready.
  • You could also create a staggered effect by streaming page sections. But you'll need to create wrapper components.

Where you place your suspense boundaries will vary depending on your application. In general, it's good practice to move your data fetches down to the components that need it, and then wrap those components in Suspense. But there is nothing wrong with streaming the sections or the whole page if that's what your application needs.

Discussion