前回
はじめに
前回はNext.jsの様々なレンダリング方法について学びました。また、遅いデータ取得がアプリケーションのパフォーマンスにどのくらい影響するのかもシミュレートして確認しました。
今回はそう言った遅いデータ取得があった場合に、ストリーミングを使用してどのようにUXを改善できるかを学んでいきます。
ストリーミングとは?
ストリーミングとは、ルートをより小さい『チャンク』という単位に分割し、準備ができ次第サーバーからクライアントに順次配信するデータ転送技術です。
ストリーミングを利用することによって、遅いデータリクエストがページ全体をブロックするのを防ぐことができます。これにより、UIがユーザーに表示される前にすべてのデータがロードされるのを待たずに、ユーザーはページの一部を見て操作することができるようになります。

ストリーミングはReactのコンポーネントモデルと相性がよく、各コンポーネントをチャンクとみなします。
Next.jsでストリーミングを実装するは以下の方法があります。
- ページレベルでは
loading.tsxファイルで実装。 - 特定のコンポーネントについては
<Suspense>を使用する。
ページ全体のストリーミング
ページ全体をストリーミングするにはloading.tsxを使用する必要があります。/app/dashboardフォルダにloading.tsxというファイルを作成して、以下のコードを追加しましょう。
export default function Loading() {
return <div>Loading...</div>;
}サーバーを起動しているのを確認したら、http://localhost:3000/dashboard/にアクセスして、読み込み中は以下のような画面が表示されるのを確認しましょう。

作成したloading.tsxではこんなことをしています。
loading.tsxはSusppenseの上に構築された特別なNext.jsのファイルで、ページのコンテンツがロードされる間に代替として表示するフォールバックUIです。<SideNav>は静的なコンポーネントなのですぐに表示されます。その他の動的コンポーネントを読み込んでいる最中も<SideNav>を使用することができます。- ユーザーはページの読み込みが終わるまでナビゲーションを待つ必要はありません。(これを中断可能なナビゲーションと呼びます)
これでストリーミングの実装は完了しました。続いて、「Loading…」というテキストではなく、ロード中を表すUIのスケルトンスクリーンを表示してみましょう。
スケルトンスクリーンの追加
スケルトンスクリーンは簡易的なUIで多くのウェブサイトで利用されています。コンテンツが読み込み中であることをユーザーに伝えるためのプレースホルダーとして使用します。loading.tsxに追加したUIは静的ファイルの一部として埋め込まれ、最初に送信されます。その後、残りの動的コンテンツがサーバーからクライアントにストリーミングされます。
loading.tsxファイル内で<DashboardSkelton>という新しいコンポーネントをインポートしましょう。
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.tsxとpage.tsxファイルをそのフォルダ内に移動させましょう。これでloadings.tsxファイルはダッシュボードの概要ページのみに適用されます。
ルートグループを使用すると、URLパスの構造に影響を与えることなく、ファイルを論理的なグループにまとめることができます。括弧()を使用して新しいフォルダを作成すると、その名前はURLパスに含まれません。つまり、/dashboard/(overview)/page.tsxは/dashboradとなります。
ここでは、loading.tsxがダッシュボードの概要ページのみ適用されるように、ルートグループを使用しています。ですが、ルートグループを使用してアプリケーションをセクションに分けたり、大規模なアプリケーションのためにチームごとに分けることもできます。
コンポーネントのストリーミング
ここまではページ全体をストリーミングしました。しかし、ReactのSuspenseを使用すればさらに細かく特定のコンテンツをストリーミングすることもできます。
Suspenseでは何らかの条件が満たされるまで、アプリケーションの一部のレンダリングを遅らせることができます。動的コンポーネントをSuspenseでラップすることができ、動的コンポーネントのロード中に表示するフォールバックコンポーネントを渡します。
以前fetchRevenue()で遅いデータリクエストを再現しました。Suspenseを使用してコンポーネントだけをストリーミングさせ、ページの残りのUIをすぐに表示させるようにしましょう。
まずはデータフェッチをコンポーネントに移動させます。続いてReactから<Suspense>をインポートして<RevenueChart />で囲み、<RevenueChartSkelton>というフォールバック・コンポーネントを渡します。
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を削除するように変更します。
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>コンポーネントを渡します。
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を受け取るのではなく、中でフェッチするように変更しましょう。
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ファイルを以下のように変更しましょう。
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>がコメントアウトされているので、コメントアウトを解除してデータをフェッチする処理を追加します。
// ...
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>でラップする方法が一般的です。

