前回
Next.jsのチュートリアルをやってみる【レンダリング編】はじめに
前回は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>でラップする方法が一般的です。