はじめに
Next.js app routerのチュートリアルの第9章のアウトプットします。
前の記事
【01】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/af58da3d20cbc790e767
【02】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/edf450b3ee135e83d1e8
【03】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/612221eac233aa9cbb74
【04】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/62f9beccbfe36eaf7f90
【05】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/8b71b1d1df7c9435a9c9
【06】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/58130c3cfbaf8a573de2
【07】Next.js app routerのチュートリアルやってみる
https://qiita.com/naoyuki2/items/2c2da0f8071e60454679
【08】Next.js app routerのチュートリアルやってみる
第9章 ストリーミング
この章では下記を学習しました。
- ストリーミング
- ページ全体をストリーミングする
loading.tsx
- ルートグループ
- コンポーネントをストリーミングする
Suspense
- ローディングスケルトン
ストリーミング
今回の場合ストリーミング
とは、ページ全体を部品に分けた時、準備が整った部品から段階的にクライアント側に表示するという転送技術のことを指します。
ストリーミングすることで、遅いデータ要求によってページ全体がブロックされるのを防ぐことができます。
これにより、ユーザーはすべてのデータが読み込まれるのを待たずに、ページの一部を表示して操作できるようになります。
loading.tsx
loading.tsx
はストリーミング
の中でも、ページ全体をストリーミング
できるものです。
試しに、/app/dashboard/laoding.tsx
ファイルをつくるとします。
import DashboardSkeleton from '@/app/ui/skeletons'
export default function Loading() {
return <div>Loading...</div>
}
すると、/dashboard
にアクセスした際にLoading...
という文字が表示されます。
そして、読み込みが完了するとダッシュボードを表示します。
-
loading.tsx
はNext.js
における特別なファイルで、ページの読み込み中に代替として表示するフォールバックUIを作成できます。 -
<SideNav />
は静的レンダリングであるため、すぐに表示されますし、クリックすることもできます。
ルートグループ
現時点では、ディレクトリは以下のようになっています。
.
└── app/
└── dashboard/
├── loading.tsx
├── page.tsx
├── layout.tsx
├── customers/
│ └── page.tsx
└── invoices/
└── page.tsx
ここで問題が一つ発生しており、loading.tsx
のLoadin...
という文字は以下の3つ
のファイルに適用さてしまうのです。
/dashboard/page.tsx
/dashboard/customers/page.tsx
/dashboard/invoices/page.tsx
.
└── app/
└── dashboard/
├── loading.tsx
├── page.tsx -- Loading...表示される
├── layout.tsx
├── customers/
│ └── page.tsx -- Loading...表示される
└── invoices/
└── page.tsx -- Loading...表示される
これを/dashboard/page.tsx
だけに適用させたいときに使うのがルートグループ
です。
ディレクトリ構成を以下のようにしてください。
.
└── app/
└── dashboard/
├── layout.tsx
├── (overview)/
│ ├── loading.tsx
│ └── page.tsx
├── customers/
│ └── page.tsx
└── invoices/
└── page.tsx
/dashboard
直下のloading.tsx
とpage.tsx
を(overview)
というフォルダの中に入れました。
これにより、loading.tsx
は、ダッシュボードの概要ページにのみ反映されます。
()
を使用して、フォルダを作成すると、URLのパス構造に影響を与えることなく論理グループに分けることができます。
そのため、ダッシュボードへのURLは/dashboard/(overview)/page.tsx
ではなく、
今まで通り、/dashboard
でアクセスできます。
.
└── app/
└── dashboard/
├── layout.tsx
├── (overview)/
│ ├── loading.tsx
│ └── page.tsx -- このファイルだけ Loading... を表示
├── customers/
│ └── page.tsx
└── invoices/
└── page.tsx
Suspense
先ほどはページ全体をストリーミングする方法を学びました。
次は、コンポーネント単位でストリーミングを実装しましょう。
そのために使用するのが、Suspense
です。
RevenuChart
コンポーネントをSuspense
でストリーミングする例を見てみましょう。
page.tsx
のrevenue
のデータをフェッチする機能を削除します。
そして、Suspense
をimport
し、RevenueChart
コンポーネントを囲っています。
fallback
には読み込み中に表示したいUIを設定できます。
import RevenueChart from '@/app/ui/dashboard/revenue-chart'
- import { fetchRevenue } from '../../lib/data'
+ import { Suspense } from 'react'
export default async function Page() {
- const revenue = await fetchRevenue
return (
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
- <RevenueChart revenue={revenue} />
+ <Suspense fallback={<div>Loading...</div>}>
+ <RevenueChart />
+ </Suspense>
</div>
)
}
そして、RevenueChart
コンポーネントがあるrevenu-chart.tsx
側で、データフェッチを行います。
そのためにasync
をつけてコンポーネントを非同期にし、
await fetchRevenue()
でrevenue
を取得しています。
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 (
// ...
);
}
この変更によってグラフが表示されるまでの間はLoading...
という文字が表示されるようになりました。
↑ グラフはまだ読み込まれていないため、
Loading...
が表示される↑
↑ 読み込まれると
Loading...
という文字が消え、グラフが表示される ↑
コンポーネントのグループ化
下のCard
コンポーネントもSuspense
で囲ってストリーミングを実装したいです。
import { Card } from '@/app/ui/dashboard/cards'
import { fetchCardData } from '../../lib/data'
export default async function Page() {
const {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData()
return (
<main>
<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>
</main>
)
}
ですが、Card
コンポーネント一つずつをSuspense
で囲ってしまうと、
4つのカードがバラバラに表示されてユーザ-体験がよろしくありません。
そこで、コンポーネントをグループ化して、Suspense
を導入しましょう。
データフェッチの処理を削除して、4つのCard
はCardWrapper
にしましょう。
import CardWrapper from '@/app/ui/dashboard/cards'
+import { Suspense } from 'react'
export default async function Page() {
return (
<main>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
+ <Suspense fallback={<div>Loading...</div>}>
+ <CardWrapper />
+ </Suspense>
</div>
</main>
)
}
CardWrapper
側にデータフェッチ処理を移します。
export default async function CardWrapper() {
const {
numberOfCustomers,
numberOfInvoices,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData()
return (
<>
{/* NOTE: comment in this code when you get to this point in the course */}
<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"
/>
</>
)
}
これによってCard
コンポーネントもストリーミングが実装できました。
ローディングスケルトン
これで完了!かと思いきや、下の動画を見てください。
グラフが表示される前にLoading...
という文字が表示されるのはいいのですが、
グラフが表示される予定の場所を確保していないため、Latest Invoices
のUIが最初は左に寄っていて、
グラフが表示されると、右に寄るという挙動になってしまいます。
これを解決するのがローディングスケルトン
です。
文字で説明するより見た方が早いので下の動画を見てください。
UIを表示する予定の場所にフレームが表示されていますね。
これがローディングスケルトン
です。
コードとしては、fallback
のところにローディングスケルトン
のUIを渡しています。
<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={<div>Loading...</div>}>
<CardWrapper />
</Suspense>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<div>Loading...</div>}>
<RevenueChart />
</Suspense>
<Suspense fallback={<div>Loading...</div>}>
<LatestInvoices />
</Suspense>
</div>
</main>
ローディングスケルトン
のUI自体は自分でスタイリングする必要があるので、結構大変そうですね。
なにか楽にできる方法ないのかな。
// Loading animation
const shimmer =
'before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent'
export function CardSkeleton() {
return (
<div
className={`${shimmer} relative overflow-hidden rounded-xl bg-gray-100 p-2 shadow-sm`}
>
<div className="flex p-4">
<div className="h-5 w-5 rounded-md bg-gray-200" />
<div className="ml-2 h-6 w-16 rounded-md bg-gray-200 text-sm font-medium" />
</div>
<div className="flex items-center justify-center truncate rounded-xl bg-white px-4 py-8">
<div className="h-7 w-20 rounded-md bg-gray-200" />
</div>
</div>
)
}
おわりに
最後にNext.jsのSuspense
についての考え方を書いておきます。
サスペンスの境界をどこに置くかは、いくつかの要素によって決まります。
- ストリーミング中にユーザーにページをどのように体験してもらいたいか。
- どのコンテンツを優先したいか。
- コンポーネントがデータの取得に依存している場合。
一般に、データのフェッチを必要なコンポーネントまで移動し、それらのコンポーネントを Suspense でラップすることをお勧めします。
ただし、アプリケーションが必要とする場合は、セクションまたはページ全体をストリーミングしても問題はありません。
Suspense は、より快適なユーザー エクスペリエンスを作成するのに役立つ強力な API なので、遠慮せずに試して何が最適かを確認してください。
次の記事