【09】Next.js app routerのチュートリアルやってみる(loading.tsxとSuspenseでストリーミング)

# はじめに

Next.js app routerのチュートリアルの第９章のアウトプットします。

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

<details><summary>前の記事</summary>

【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のチュートリアルやってみる

https://qiita.com/naoyuki2/items/45f45fcb9cc14506f79f

</details>

# 第９章 ストリーミング

この章では下記を学習しました。

- ストリーミング
- ページ全体をストリーミングする`loading.tsx`
- ルートグループ
- コンポーネントをストリーミングする`Suspense`
- ローディングスケルトン

# ストリーミング

今回の場合`ストリーミング`とは、ページ全体を部品に分けた時、準備が整った部品から段階的にクライアント側に表示するという転送技術のことを指します。

ストリーミングすることで、遅いデータ要求によってページ全体がブロックされるのを防ぐことができます。


これにより、ユーザーはすべてのデータが読み込まれるのを待たずに、ページの一部を表示して操作できるようになります。

# loading.tsx

`loading.tsx`は`ストリーミング`の中でも、ページ全体を`ストリーミング`できるものです。

試しに、`/app/dashboard/laoding.tsx`ファイルをつくるとします。

```tsx:/app/dashboard/laoding.tsx
import DashboardSkeleton from '@/app/ui/skeletons'

export default function Loading() {
    return <div>Loading...</div>
}

```

すると、`/dashboard`にアクセスした際に`Loading...`という文字が表示されます。

そして、読み込みが完了するとダッシュボードを表示します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582046/4356b6d1-1c7d-e4ea-555a-bfa6a0e4a45d.png)

- `loading.tsx`は`Next.js`における特別なファイルで、ページの読み込み中に代替として表示するフォールバックUIを作成できます。

- `<SideNav />`は静的レンダリングであるため、すぐに表示されますし、クリックすることもできます。

# ルートグループ

現時点では、ディレクトリは以下のようになっています。

```
.
└── app/
    └── dashboard/
        ├── loading.tsx
        ├── page.tsx
        ├── layout.tsx
        ├── customers/
        │   └── page.tsx
        └── invoices/
            └── page.tsx
```

ここで問題が一つ発生しており、`loading.tsx`の`Loadin...`という文字は以下の`３つ`のファイルに適用さてしまうのです。

- `/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を設定できます。

```diff_jsx:/app/dashboard/page.tsx
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`を取得しています。

```diff_tsx:/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() {
+ 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...`という文字が表示されるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582046/a11f8e71-3cf6-2b48-c9e8-cf0736aa6cac.png)

> ↑ グラフはまだ読み込まれていないため、 `Loading...`が表示される↑

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582046/dbc5af13-e05a-cc4e-5088-69796894cc26.png)

> ↑ 読み込まれると`Loading...`という文字が消え、グラフが表示される ↑

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

下の`Card`コンポーネントも`Suspense`で囲ってストリーミングを実装したいです。

```diff_tsx:/app/dashboard/page.tsx

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`で囲ってしまうと、

４つのカードがバラバラに表示されてユーザ－体験がよろしくありません。

そこで、コンポーネントをグループ化して、`Suspense`を導入しましょう。

データフェッチの処理を削除して、４つの`Card`は`CardWrapper`にしましょう。

```diff_tsx:/app/dashboard/page.tsx
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`側にデータフェッチ処理を移します。

```/app/ui/dashboard/cards.tsx
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`コンポーネントもストリーミングが実装できました。

# ローディングスケルトン

これで完了!かと思いきや、下の動画を見てください。

![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582046/6201dd05-26e6-3ddf-16ca-56edfa310dab.gif)

グラフが表示される前に`Loading...`という文字が表示されるのはいいのですが、

グラフが表示される予定の場所を確保していないため、`Latest Invoices`のUIが最初は左に寄っていて、

グラフが表示されると、右に寄るという挙動になってしまいます。

---

これを解決するのが`ローディングスケルトン`です。

文字で説明するより見た方が早いので下の動画を見てください。

![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3582046/d234e866-d250-3b8b-a547-ff43c861c714.gif)

UIを表示する予定の場所にフレームが表示されていますね。

これが`ローディングスケルトン`です。

コードとしては、`fallback`のところに`ローディングスケルトン`のUIを渡しています。

```tsx:/app/dashboard\page.tsx
<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自体は自分でスタイリングする必要があるので、結構大変そうですね。

なにか楽にできる方法ないのかな。

```tsx:Cardコンポーネントのローディングスケルトンの例
// 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 なので、遠慮せずに試して何が最適かを確認してください。


# 次の記事

https://qiita.com/naoyuki2/items/8062f755b0679fe925b1