🍀

【Next.js】超簡単!! Suspenseを活用したStreamingとSkeleton loadingの実装方法まとめ

に公開

はじめに

Learn Next.jsでStreaming機能を勉強したので、その振り返り記事を記載していきます。

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

環境

Next.js 15.2.3

今回実装する機能について

Suspenseとは何か

Suspenseは、Reactのコンポーネントがデータの読み込みや準備が完了するまで、代替UI(フォールバック)を表示する機能です。
Next.jsのApp Routerは、このSuspenseと連携してStreaming SSRを実現します。
https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming

Suspenseの仕組み

  1. Suspenseで囲まれたコンポーネントがレンダリングを開始します。
  2. データ取得などの非同期処理が完了するまでfallbackコンポーネントを表示します。
  3. 非同期処理が完了したら、本来のコンポーネントを表示します。

Streamingとは何か

Streamingとは、ページ全体の読み込みを待たずに、準備ができた部分から順次ユーザーに表示する技術です。
Streamingを使用すると、ページを小さな「チャンク」に分割し、準備ができたチャンクから順番にユーザーに表示され、操作することができます。

Streamingが必要な理由

1.初期表示の高速化: ユーザーは即座にページの要素を見ることができるため、アプリケーションが応答していると感じられます。
2.インタラクティブ性の向上: 読み込みが完了したコンポーネントからすぐに操作できるようになります。
3.UXの向上: 一部のデータ取得が遅くても、他の部分を先に閲覧・使用できるため、待ち時間の体感を軽減し、全体的な満足度が高まります。

特に重要なのは、データ依存のあるコンポーネントだけを遅延させ、残りの部分をすぐに表示できる点です。

Skeleton loadingとは何か

Skeleton loadingは、実際のコンテンツが読み込まれる前に、グレーの四角や線などでコンテンツの輪郭を表示するために使用されます。
Suspenseのfallbackとして活用されることが多いです。

Skeleton loadingが必要な理由

1.ユーザーの認知負荷の軽減: 読み込み中であることが明確に示され、最終的にどのようなコンテンツが表示されるかの予測がつきます。
2.体感速度の向上: 単なるローディングスピナーと比べて、アプリケーションが「より速く」感じられます。
https://ascii.jp/elem/000/001/245/1245705/#:~:text=てください。-,スケルトンスクリーンで錯覚させる,-Webサイトが
3.レイアウトシフトの防止 - コンテンツが読み込まれる際のレイアウトの突然の変化(累積レイアウトシフト/CLS)を最小限に抑えることができます

特に3つ目の「レイアウトシフトの防止」は、Web Vitalsの重要な指標であるCLS(Cumulative Layout Shift)の改善に直結します。
https://web.dev/articles/cls?hl=ja

レイアウトシフトはユーザー体験を大きく損なう原因となり、以下のような問題を引き起こします:

  • ユーザーが読んでいるテキストが突然移動して、読み進めにくくなります。
  • 意図したボタンではなく、読み込み後に表示された別のボタンを誤ってクリックしてしまう可能性があります。
  • 最悪の場合、キャンセルしようとしていた注文を誤って確定してしまうなどの重大な問題も起こり得ます。

ただし、Streamingとスケルトンローディングの実装には注意点もあります。
スケルトンのサイズと実際のコンテンツのサイズが大きく異なる場合、かえってレイアウトシフトを引き起こす可能性があります。
これはTTFB(Time to First Byte)とCLSのトレードオフとなることがあり、コンポーネントの読み込み時間によって最適な戦略が変わってきます。

Next.jsでの実装方法

1. Next.jsのアプリの作成

ターミナルで以下を入力します。

npx create-next-app@latest sample-app

プロジェクトが作成されたら、ディレクトリに移動し、開発サーバーを起動します。

cd sample-app
npm run dev

ブラウザで http://localhost:3000 にアクセスすると、デフォルトのNext.jsのページが表示されます。

2. 基本的なページとコンポーネントの作成

1. 型定義とダミーデータの設定

app/lib/types.ts
export type Revenue = {
  month: string;
  revenue: number;
};

export type Invoice = {
  id: string;
  name: string;
  email: string;
  amount: number;
};

export type BillingStatsData = {
  numberOfInvoices: number;
  numberOfCustomers: number;
  totalPaidInvoices: number;
  totalPendingInvoices: number;
};
app/lib/data.ts
import { Revenue, Invoice, BillingStatsData } from './types';

// 意図的に遅いデータ取得をシミュレートするヘルパー関数
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

// 売上データの取得(遅い)
export async function fetchRevenue(): Promise<Revenue[]> {
  await delay(3000); // 3秒の遅延
  const revenue: Revenue[] = [
    { month: "1月", revenue: 2000 },
    { month: "2月", revenue: 1800 },
    { month: "3月", revenue: 2200 },
    { month: "4月", revenue: 2500 },
    { month: "5月", revenue: 2300 },
    { month: "6月", revenue: 3000 },
    { month: "7月", revenue: 2800 },
    { month: "8月", revenue: 3200 },
    { month: "9月", revenue: 3500 },
    { month: "10月", revenue: 3700 },
    { month: "11月", revenue: 3900 },
    { month: "12月", revenue: 4100 },
  ];
  return revenue;
}

// 最新の請求書データの取得(中程度の遅さ)
export async function fetchLatestInvoices(): Promise<Invoice[]> {
  await delay(2000); // 2秒の遅延
  const invoices: Invoice[] = [
    {
      id: "INV001",
      name: "山田太郎",
      email: "taro@example.com",
      amount: 15000,
    },
    {
      id: "INV002",
      name: "佐藤花子",
      email: "hanako@example.com",
      amount: 20000,
    },
    {
      id: "INV003",
      name: "鈴木一郎",
      email: "ichiro@example.com",
      amount: 8000,
    },
    {
      id: "INV004",
      name: "田中美咲",
      email: "misaki@example.com",
      amount: 12000,
    },
    {
      id: "INV005",
      name: "伊藤健太",
      email: "kenta@example.com",
      amount: 25000,
    },
  ];
  return invoices;
}

// 請求書データの取得(速い)
export async function fetchBillingStats(): Promise<BillingStatsData> {
  await delay(1000); // 1秒の遅延
  return {
    numberOfInvoices: 123,
    numberOfCustomers: 85,
    totalPaidInvoices: 2510000,
    totalPendingInvoices: 750000,
  };
}

2. ダッシュボードページの作成

トップページにダッシュボードページへのリンクを設定します。

app/page.tsx
import Link from 'next/link';

export default function Home() {
  return (
    <Link
      href="/dashboard"
    >
      ダッシュボードを表示
    </Link>
  );
}
app/dashboard/page.tsx
import { Suspense } from 'react';
import BillingStats from '@/app/ui/dashboard/billing-stats';
import RevenueSummary from '@/app/ui/dashboard/revenue-summary';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { BillingStatsSkeleton, RevenueSummarySkeleton, LatestInvoicesSkeleton } from '@/app/ui/skeletons';

export default function DashboardPage() {
  return (
    <main className="p-6">
      <h1 className="text-2xl font-bold mb-4">ダッシュボード</h1>

      <Suspense fallback={<BillingStatsSkeleton />}>
        <BillingStats />
      </Suspense>

      <Suspense fallback={<RevenueSummarySkeleton />}>
        <RevenueSummary />
      </Suspense>

      <Suspense fallback={<LatestInvoicesSkeleton />}>
        <LatestInvoices />
      </Suspense>
    </main>
  );
}

この例では、3つの異なるデータ取得コンポーネントをそれぞれSuspenseで囲み、対応するスケルトンをフォールバックとして設定しています。これにより、各コンポーネントのデータ取得が完了するまで、それぞれのスケルトンが表示されます。

サスペンスの境界線をどこに置くか決める

公式の記載には以下の記載があります。

サスペンスの境界をどこに置くかは、いくつかの要素によって決まります。

  1. ページのStreaming中にユーザーにどのようにページを体験してもらいたいか
  2. 優先したいコンテンツ
  3. コンポーネントがデータ取得に依存している場合

ページ全体をStreaming: loading.tsxを使用すると簡単ですが、どこか1つのコンポーネントでもデータ取得が遅い場合は全体の読み込み時間が長くなる可能性があります。
個別コンポーネントをStreaming: 各コンポーネントを個別にSuspenseで囲むと、準備ができたものから表示されますが、UI要素が段階的に表示されるポップイン効果が生じる可能性があります。
ページセクションをStreaming: 段階的な効果を作成することもできます。
サスペンスの境界をどこに置くかは、アプリケーションによって異なります。一般的には、データ フェッチを必要とするコンポーネントまで移動し、それらのコンポーネントをサスペンスでラップするのが良い方法です。ただし、アプリケーションで必要な場合は、セクションまたはページ全体をStreamingしても問題ありません。

https://nextjs.org/learn/dashboard-app/streaming#deciding-where-to-place-your-suspense-boundaries

TTFBとCLSのバランス

Streaming SSRを活用すると、ユーザーに即座に画面を表示し始めることができますが、fallbackからコンテンツへの切り替え時にレイアウトシフトが発生する可能性があります。

対応策と考慮点

  • 固定サイズのスケルトン: コンテンツと同じサイズのスケルトンを用意することでレイアウトシフトを防げます。
  • コンポーネントの重要度: ユーザーがすぐに見る必要があるコンテンツか、スクロールして見るコンテンツかも考慮する必要があります。
  • ページの読み込み速度が速い場合: スケルトンを表示するよりも完全なコンテンツを少し待って一度に表示した方が良いこともあります。画面のちらつきを防げるため、ケースバイケースで導入を検討する必要があります。

3. ダッシュボードで表示するデータの作成

app/ui/dashboard/billing-stats.tsx
import { fetchBillingStats } from '@/app/lib/data';

interface CardProps {
  title: string;
  value: number;
  isCurrency?: boolean;
}

export function Card({ title, value, isCurrency = false }: CardProps) {
  return (
    <div className="bg-white p-4 rounded-lg shadow">
      <h2 className="text-gray-500 text-sm">{title}</h2>
      <p className="text-2xl font-bold">{isCurrency ? `¥${value.toLocaleString()}` : value.toLocaleString()}</p>
    </div>
  );
}

export default async function BillingStats() {
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchBillingStats();

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
      <Card title="回収済み" value={totalPaidInvoices} isCurrency={true} />
      <Card title="未回収" value={totalPendingInvoices} isCurrency={true} />
      <Card title="請求書総数" value={numberOfInvoices} />
      <Card title="顧客数" value={numberOfCustomers} />
    </div>
  );
}
app/ui/dashboard/latest-invoices.tsx
import { fetchLatestInvoices } from '@/app/lib/data';
import { Invoice } from '@/app/lib/types';

export default async function LatestInvoices() {
  const latestInvoices = await fetchLatestInvoices();

  return (
    <div className="bg-white p-4 rounded-lg shadow">
      <h2 className="text-gray-500 text-sm mb-4">最新の請求書</h2>
      <div className="space-y-4">
        {latestInvoices.map((invoice: Invoice) => (
          <div key={invoice.id} className="flex items-center justify-between border-b pb-2">
            <div className="flex items-center">
              <div className="w-8 h-8 bg-gray-200 rounded-full mr-2"></div>
              <div>
                <p className="font-medium">{invoice.name}</p>
                <p className="text-gray-500 text-sm">{invoice.email}</p>
              </div>
            </div>
            <p className="font-medium">¥{invoice.amount.toLocaleString()}</p>
          </div>
        ))}
      </div>
    </div>
  );
}
app/ui/dashboard/revenue-summary.tsx
import { fetchRevenue } from '@/app/lib/data';

export default async function RevenueSummary() {
  const revenue = await fetchRevenue();

  // 合計と平均を計算
  const totalRevenue = revenue.reduce((sum, item) => sum + item.revenue, 0);
  const averageRevenue = Math.round(totalRevenue / revenue.length);

  // 最高売上の月を取得
  const bestMonth = revenue.reduce((max, item) => max.revenue > item.revenue ? max : item, revenue[0]);

  return (
    <div className="bg-white p-4 rounded-lg shadow mb-6">
      <h2 className="text-gray-500 font-medium mb-4">売上サマリー</h2>

      <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
        <div className="p-3 bg-blue-50 rounded-lg">
          <div className="text-sm text-gray-500">総売上</div>
          <div className="text-xl font-bold">{totalRevenue.toLocaleString()}</div>
        </div>
        <div className="p-3 bg-green-50 rounded-lg">
          <div className="text-sm text-gray-500">月平均</div>
          <div className="text-xl font-bold">{averageRevenue.toLocaleString()}</div>
        </div>
        <div className="p-3 bg-yellow-50 rounded-lg">
          <div className="text-sm text-gray-500">最高月</div>
          <div className="text-xl font-bold">{bestMonth.month}</div>
        </div>
        <div className="p-3 bg-purple-50 rounded-lg">
          <div className="text-sm text-gray-500">最高売上</div>
          <div className="text-xl font-bold">{bestMonth.revenue.toLocaleString()}</div>
        </div>
      </div>

      <h3 className="text-gray-500 text-sm mb-2">月別売上</h3>
      <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
        {revenue.map((item) => (
          <div key={item.month} className="p-2 bg-gray-50 rounded border border-gray-100">
            <div className="text-xs text-gray-500">{item.month}</div>
            <div className="font-medium">{item.revenue.toLocaleString()}</div>
          </div>
        ))}
      </div>
    </div>
  );
}

4. Skeleton loadingの実装

app/ui/skeletons.tsx
export function BillingStatsSkeleton() {
  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
      {[...Array(4)].map((_, i) => (
        <div key={i} className="bg-white p-4 rounded-lg shadow animate-pulse">
          <div className="h-2 bg-gray-200 rounded w-16 mb-2"></div>
          <div className="h-9 bg-gray-200 rounded w-24"></div>
        </div>
      ))}
    </div>
  );
}

export function RevenueSummarySkeleton() {
  return (
    <div className="bg-white p-4 rounded-lg shadow mb-6 animate-pulse">
      <div className="h-5 w-32 bg-gray-200 rounded mb-4"></div>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
        {[...Array(4)].map((_, i) => (
          <div key={i} className="p-3 bg-gray-100 rounded-lg">
            <div className="h-3 w-16 bg-gray-200 rounded mb-2"></div>
            <div className="h-8 w-20 bg-gray-200 rounded"></div>
          </div>
        ))}
      </div>
      <div className="h-4 w-20 bg-gray-200 rounded mb-2"></div>
      <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
        {[...Array(12)].map((_, i) => (
          <div key={i} className="p-2 bg-gray-100 rounded border border-gray-100">
            <div className="h-3 w-8 bg-gray-200 rounded mb-1"></div>
            <div className="h-6 w-16 bg-gray-200 rounded"></div>
          </div>
        ))}
      </div>
    </div>
  );
}

export function LatestInvoicesSkeleton() {
  return (
    <div className="bg-white p-4 rounded-lg shadow animate-pulse">
      <div className="h-2 bg-gray-200 rounded w-24 mb-4"></div>
      {[...Array(5)].map((_, i) => (
        <div key={i} className="flex items-center justify-between border-b pb-2 mb-4">
          <div className="flex items-center">
            <div className="w-8 h-8 bg-gray-200 rounded-full mr-2"></div>
            <div>
              <div className="h-2 bg-gray-200 rounded w-24 mb-2"></div>
              <div className="h-2 bg-gray-200 rounded w-32"></div>
            </div>
          </div>
          <div className="h-2 bg-gray-200 rounded w-16"></div>
        </div>
      ))}
    </div>
  );
}

Image from Gyazo

まとめ

Suspenseを活用したStreamingとSkeleton loadingの導入はユーザー体験の向上に繋がります。
UX/UIを向上させるためのちょっとした工夫、大事ですね。

Discussion