🚀

【9章】Next.jsのチュートリアルをやってみた

2025/01/12に公開

前章のメモ

https://zenn.dev/kuuki/articles/nextjs-tutorial-08/

Next.js の公式チュートリアルの該当ページ

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

学ぶこと

  • ストリーミングとは何か?いつ使うのか?
  • loading.tsxSuspenseを使用してストリーミングを実装する方法
  • スケルトンスクリーンとはなにか?
  • ルートグループとは何か?いつ使うのか?
  • Suspense の境界をどこにするか?

ストリーミングとは何か?

ストリーミングとは、

  • ページを小さな塊(チャンク)に分割し、準備できたものから表示していく技術
  • コンポーネント単位でチャンクに分割。
  • 処理が遅いコンポーネントの影響でページが表示されないことを防ぐ。

実装方法としては

  • ページに対しては、loading.tsx を使う
  • コンポーネントに対しては、<Suspense> を使う

の 2 通り。

ページ全体をストリーミング

loading.tsx

ダッシュボードページでストリーミングを使ってみます!

ページ全体に適用させたいので /app/dashboard/loading.tsxを作成します

/app/dashboard/loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}

ローカルサーバを起動して http://localhost:3000/dashboard にアクセスするとLoadingと表示

数秒後、今までのダッシュボード画面が表示されるようになりました

ポイントとしては

  1. ページ読み込み中にloading.tsxで定義した UI が表示される
  2. <SideNav>は静的なので、データ取得中でも表示される
  3. ページ読み込み中でも別のページに遷移できる

スケルトンスクリーンの実装

スケルトンスクリーンとは、ページ読み込み中に灰色のコンテンツが表示されるもの。

Youtube とかで使われています(読み込み中はサムネが灰色になっていますね)

今回のチュートリアルでは、すでにこの UI(DashboardSkeleton)が用意されています

/app/dashboard/loading.tsxで DashboardSkeleton をインポートして使ってみましょう

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

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

http://localhost:3000/dashboard  をリロードしてみると、、、

スケルトンスクリーンが実装されていますね!

スケルトンスクリーンをダッシュボード画面でのみ使用する

現在の /app/dashboard/ 配下は ↓。

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

/app/dashboard/loading.tsxでスケルトンスクリーンを実装したんですが、

  • /app/dashboard/

だけでなく、

  • /app/dashboard/customers/
  • /app/dashboard/invoices/

にも適用されるんですね。これが。

本アプリでは、/app/dashboard/  だけに適用したいのでRoute Groupsという機能を使います

これは、フォルダ名を () で囲うことでパスに影響を与えず、フォルダ構成をいじれます

今回の場合だと、/app/dashboard/ 配下に (overview) というフォルダを作成します。

そこに page.tsxloading.tsx  を入れます(↓ 参照)

// フォルダ構成変更後
/app/dashboard/
├── (overview)        // 新規作成
│   ├── loading.tsx    // ここに移動
│   └── page.tsx      // ここに移動
├── customers
│   └── page.tsx
├── invoices
│   └── page.tsx
└── layout.tsx

(overview) フォルダはパスに影響を与えないので、

http://localhost:3000/dashboard にアクセスすると /app/dashboard/(overview)/page.tsx を表示。

このようにパスを変更せずにファイルの影響範囲をコントロールできるのがRoute Groups

コンポーネントをストリーミング

Suspense

ページ全体ではなく特定のコンポーネントをストリーミングするにはSuspenseを使います

現状 fetchRevenue() の処理を重たくしているので

このデータを使っている<RevenueChart>に Suspense を使ってストリーミング。

コードとしては、

  1. <RevenueChart>で fetchRevenue() を実行するように変更
  2. <Suspense>を<RevenueChart>に適用

Suspense は囲ったコンポーネントに効くので

そのコンポーネント内で重たい処理をさせないと意味がないのかなと。

なので、fetchRevenue() の実行場所を移しています

まずは、/app/dashboard/(overview)/page.tsxから fetchRevenue() を削除

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import { lusitana } from '@/app/ui/fonts';
import {
  fetchCardData,
  fetchLatestInvoices,
  fetchRevenue,   // 削除
} from '@/app/lib/data';

export default async function Page() {
  const revenue = await fetchRevenue();   // 削除
  const latestInvoices = await fetchLatestInvoices();

....

<RevenueChart>で fetchRevenue() を実行します。

また、引数で受け取る必要がなくなったので削除します

import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { Revenue } from '@/app/lib/definitions';  // 削除
import { fetchRevenue } from '@/app/lib/data';    // 追加

// This component is representational only.
// For data visualization UI, check out:
// https://www.tremor.so/
// https://www.chartjs.org/
// https://airbnb.io/visx/

export default async function RevenueChart() {    // 引数削除
  const revenue = await fetchRevenue();    // 追加

  const chartHeight = 350;

...

最後に引数を削除したのでそれも対応しつつ<RevenueChart>を<Suspense>で囲みます

Suspense のfallbackには子コンポーネントが表示されるまでの UIを設定。

import { Card } from '@/app/ui/dashboard/cards';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import { lusitana } from '@/app/ui/fonts';
import { fetchCardData, fetchLatestInvoices } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';

export default async function Page() {

...

      <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>
  );
}

ローカルサーバを立ち上げて

http://localhost:3000/dashboard にアクセスして左のグラフがスケルトンスクリーンなら OK!

よく見ると初めの方はすべてスケルトンスクリーンになっているので、 /app/dashboard/(overview)/loading.tsx も効いていますね!!

<LatestInvoices> もストリーミングしてみる

流れはさっきといっしょ。

  1. <LatestInvoices>で fetchLatestInvoices() を実行するように変更
  2. <Suspense>を<LatestInvoices>に適用

まずは、/app/dashboard/(overview)/page.tsxから fetchLatestInvoices() を削除

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import { lusitana } from '@/app/ui/fonts';
import {
  fetchCardData,
  fetchLatestInvoices,   // 削除
} from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';

export default async function Page() {
  const latestInvoices = await fetchLatestInvoices();   // 削除
  const {
    numberOfCustomers,
    numberOfInvoices,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();


....

<LatestInvoices>で fetchLatestInvoices() を実行します。

また、引数で受け取る必要がなくなったので削除します

import { ArrowPathIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import Image from 'next/image';
import { lusitana } from '@/app/ui/fonts';
import { LatestInvoice } from '@/app/lib/definitions';  // 削除
import { fetchLatestInvoices } from '@/app/lib/data';   // 追加

export default async function LatestInvoices() {        // 引数削除
  const latestInvoices = await fetchLatestInvoices();   // 追加


...

最後に引数を削除したのでそれも対応しつつ<LatestInvoices>を<Suspense>で囲みます

Suspense のfallbackには子コンポーネントが表示されるまでの UIを設定。

import { Card } from '@/app/ui/dashboard/cards';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
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() {

...

      <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>
  );
}

スケルトンスクリーンを実装できたかわかりやすくするために fetchLatestInvoices() を変更。

fetchRevenue() よりも実行時間が長くなるようにしておく。

// 一部抜粋
export async function fetchLatestInvoices() {
  noStore();

  try {
    await new Promise((resolve) => setTimeout(resolve, 5000));

    const data = await sql<LatestInvoiceRaw>`
      SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id
      FROM invoices
      JOIN customers ON invoices.customer_id = customers.id
      ORDER BY invoices.date DESC
      LIMIT 5`;

    const latestInvoices = data.rows.map((invoice) => ({
      ...invoice,
      amount: formatCurrency(invoice.amount),
    }));
    return latestInvoices;
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch the latest invoices.');
  }
}

ローカルサーバを立ち上げて

http://localhost:3000/dashboard にアクセスしてグラフの右側がスケルトンスクリーンなら OK!

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

つづいて<Card>コンポーネントを Streaming したいのですが、コンポーネントが複数あります。

個々に Suspense で囲うと表示できるようになったコンポーネントから順に表示され

ユーザが不快に感じる可能性があります。

また、複数のコンポーネントを一気に Suspense で囲っても大丈夫です。

が、今回は同じコンポーネントであり、fetchCardData() が複数回はしるのがよろしくない。

そんな時はラッパーコンポーネントを使っていきます

つまり、<Card>を4つ含む新しいコンポーネントを作成しそれを Suspense で囲います

コードとしては

  1. <CardWrapper>のコメントアウトを外し fetchCardData() を実行するように変更
  2. <Card>を削除して、<Suspense>を<CardWrapper>に適用

今回のアプリではラッパーコンポーネントである<CardWrapper>があるので、

その中身のコメントアウトを外します

また、fetchCardData() を実行するようにコードを追加します

import {
  BanknotesIcon,
  ClockIcon,
  UserGroupIcon,
  InboxIcon,
} from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchCardData } from '@/app/lib/data';

const iconMap = {
  collected: BanknotesIcon,
  customers: UserGroupIcon,
  pending: ClockIcon,
  invoices: InboxIcon,
};

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"
      />
    </>
  );
}

...

/app/dashboard/page.tsxから<Card>をすべて削除。

代わりに<Suspense>で囲った<CardWrapper>を追加。

import CardWrapper from '@/app/ui/dashboard/cards';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import { lusitana } from '@/app/ui/fonts';
import { fetchCardData } from '@/app/lib/data';   // 削除
import { Suspense } from 'react';
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton,
} 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">
        <Suspense fallback={<CardsSkeleton/>}>
          <CardWrapper />
        </Suspense>
      </div>
...

スケルトンスクリーンを実装できたかわかりやすくするために fetchCardData() を変更。

fetchRevenue() よりも実行時間が長くなるようにしておく。

// 一部抜粋
export async function fetchCardData() {
  noStore();

  try {
    // You can probably combine these into a single SQL query
    // However, we are intentionally splitting them to demonstrate
    // how to initialize multiple queries in parallel with JS.
    const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
    const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
    const invoiceStatusPromise = sql`SELECT
         SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
         SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
         FROM invoices`;

    await new Promise((resolve) => setTimeout(resolve, 5000));

    const data = await Promise.all([
      invoiceCountPromise,
      customerCountPromise,
      invoiceStatusPromise,
    ]);

    const numberOfInvoices = Number(data[0].rows[0].count ?? '0');
    const numberOfCustomers = Number(data[1].rows[0].count ?? '0');
    const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0');
    const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0');

    return {
      numberOfCustomers,
      numberOfInvoices,
      totalPaidInvoices,
      totalPendingInvoices,
    };
  } catch (error) {
    console.error('Database Error:', error);
    throw new Error('Failed to fetch card data.');
  }
}

ローカルサーバを立ち上げて

http://localhost:3000/dashboard にアクセスして画面上部のカードがスケルトンスクリーンなら OK!

Streaming はページ全体?単一もしくは複数コンポーネント?

Streaming をページ全体やコンポーネント単位に適用させる方法を見てきました。

では、どう使い分ければいいのでしょうか??

データを取得する時間や UX など各アプリごとに要件が異なるので一概には定義できません

結局はメリデメを把握して自分で選択できるようになるしかないのかなと。

今回だと

  • ページ全体:loading.tsx で一気に定義できるが処理が長いコンポーネントに引っ張られる
  • 単一のコンポーネント:個別に制御できるが個々に表示されるようになる
  • 複数のコンポーネント:段階的に表示できるが、ラッパーコンポーネントが必要

いろいろ試してみてその時の最適解を探してみましょう!!

次章のメモ

https://zenn.dev/kuuki/articles/nextjs-tutorial-10/

Discussion