モダンReact + Next.js #5 Suspenseとuse()によるローディング処理

読了 10分

前回はServer Componentでデータを取得する単純なパターンを扱いました。しかしこれまで作ったページはすべてのデータが届くまで画面が表示されません。ページの中で速いデータと遅いデータが混在していても、最も遅い方に合わせて全員が待つわけです。今回の記事ではその問題を解決するツール — Suspense、loading.jsuse()フックを扱います。

問題 — All-or-Nothing #

次のページを想像してみてください。

問題の状況
export default async function Page() {
  const profile = await getProfile();   // 100ms (速い)
  const posts = await getPosts();       // 2000ms (遅い)
  const stats = await getStats();       // 3000ms (最も遅い)

  return (
    <div>
      <Profile data={profile} />
      <Posts data={posts} />
      <Stats data={stats} />
    </div>
  );
}

このページは3秒間白い画面です。profileは100msで準備できているのに、statsが終わるまで画面に出てこられません。

並列化(Promise.all)は助けになりますが、本質を解決はしません。どうせ最も遅い方が終わるまで画面全体が待たなければならないからです。

本当の解決は準備された部分から表示し、残りは準備でき次第後で埋めることです。これを可能にするのがSuspensestreamingです。

Suspenseの基本概念 #

<Suspense>は「この中のコンポーネントがまだ準備できていなければfallbackを代わりに表示し、準備できたら入れ替えてくれ」とReactに伝える目印です。

Suspense 基本
import { Suspense } from 'react';

<Suspense fallback={<p>ローディング中...</p>}>
  <SlowComponent />
</Suspense>

SlowComponentがデータフェッチなどで時間がかかると、その間<p>ローディング中...</p>が表示され、準備が終わると自動的に入れ替わります。

これ自体が強力なのは、Suspenseの内側と外側が独立して動作する点です。上の例でページの他の部分はSlowComponentを待たずにすぐに描画できます。

Server Components + Suspense = Streaming #

Server ComponentでSuspenseを使うと本当に強力になります。上の問題コードをこう変えましょう。

Suspense で分離
import { Suspense } from 'react';

export default async function Page() {
  const profile = await getProfile();  // 100ms は待っても OK

  return (
    <div>
      <Profile data={profile} />

      <Suspense fallback={<p>ポスト読み込み中...</p>}>
        <PostsSection />
      </Suspense>

      <Suspense fallback={<p>統計読み込み中...</p>}>
        <StatsSection />
      </Suspense>
    </div>
  );
}

async function PostsSection() {
  const posts = await getPosts();   // 2000ms
  return <Posts data={posts} />;
}

async function StatsSection() {
  const stats = await getStats();   // 3000ms
  return <Stats data={stats} />;
}

これで起こること:

時系列の流れ
0ms      サーバーがページのレンダリングを開始
100ms    profile 到着 → Profile 部分 + Suspense fallback がクライアントに送信
         (ユーザー: profile は見える、残りは "ローディング中..." を表示)
2000ms   posts 到着 → サーバーが Posts の HTML を追加でクライアントに送る
         (ユーザー: Posts 領域が自動的に fallback から実際の内容に置き換わる)
3000ms   stats 到着 → 同様に Stats 領域も置き換わる

ページが段階的に埋まっていくわけです。速い部分は速く、遅い部分は自分のペースで。これをstreamingと呼びます。

ユーザー目線では白い画面が消える時間が3秒 → 100msに短縮されます。データが届くのにかかる時間自体は変わりませんが、体感速度は劇的に良くなります。

loading.js — ページ全体のfallback #

ページ全体を1つの単位としてSuspenseで包みたいときのための短縮構文があります。フォルダにloading.jsファイルを置けば良いです。

ルート構造
src/app/
├── layout.js
└── posts/
    ├── loading.js      ← 自動的に Suspense fallback になる
    └── page.js

src/app/posts/loading.js:

src/app/posts/loading.js
export default function Loading() {
  return (
    <div style={{ padding: '24px' }}>
      <p>ポストページ読み込み中...</p>
    </div>
  );
}

/postsに移動するとpage.jsのデータが準備される間この画面が表示され、準備できると自動的に入れ替わります。<Suspense>でページを包んだのと同じ効果です。

これはページ全体が1つの単位としてロードされるときに便利な方式で、より細かいstreaming(速い部分は先に表示、遅い部分だけfallback)を望むなら、ページの中で直接<Suspense>を使えば良いです。

Suspense境界をどこに置くか #

Suspenseを効果的に使うには境界をどこに引くべきかの感覚が必要です。ガイドライン:

  1. 速いデータと遅いデータを別のSuspenseに置く — 遅い方が速い方を遮らないように
  2. ユーザー体験上一緒に表示される方が自然な部分は同じSuspenseに — 例: 記事タイトルと作成者
  3. 細かく分けすぎない — すべての小さな部分にfallbackを置くと画面がチラつきのパッチワークになる

Skeleton fallback #

「ローディング中…」のテキストよりも、実際のコンテンツに似た形のskeletonがユーザー体験に良いです。画面レイアウトが事前に確保されていれば、本物のコンテンツが届いたときにジャンプがないからです。

src/app/posts/loading.js (skeleton)
function Skeleton({ width, height }) {
  return (
    <div style={{
      width,
      height,
      background: '#eee',
      borderRadius: '4px',
      animation: 'pulse 1.5s ease-in-out infinite',
    }} />
  );
}

export default function Loading() {
  return (
    <div style={{ padding: '24px' }}>
      <Skeleton width="60%" height="32px" />
      <div style={{ marginTop: '16px' }}>
        <Skeleton width="100%" height="60px" />
        <Skeleton width="100%" height="60px" />
        <Skeleton width="100%" height="60px" />
      </div>
    </div>
  );
}

(globals.cssに@keyframes pulse { ... }の定義が必要な点に留意)

コンテンツと同じ位置に同じサイズでplaceholderを置いておけば、コンテンツ到着時に自然に入れ替わります。ユーザーは白いチラつきのない滑らかな遷移を見ることになります。

use()フック — Promiseをコンポーネントで直接解く #

React 19で新しく安定化されたフックuseはPromiseを受け取ってその結果値を返します。次の2つのシナリオで意味が大きいです。

シナリオ1. Server ComponentからClient ComponentへPromiseを渡す #

データフェッチはServerで開始したいが、その結果を使うコンポーネントはインタラクションが必要でClientである必要があるとします。

src/app/posts/page.js (Server)
import PostList from './PostList';

export default function Page() {
  const postsPromise = fetch('https://api.example.com/posts')
    .then(r => r.json());

  return (
    <Suspense fallback={<p>読み込み中...</p>}>
      <PostList postsPromise={postsPromise} />
    </Suspense>
  );
}
src/app/posts/PostList.jsx (Client)
'use client';

import { use } from 'react';

export default function PostList({ postsPromise }) {
  const posts = use(postsPromise);

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

サーバーでPromiseを作ってクライアントに渡し、クライアントでuse(promise)で解いて使うパターンです。awaitを使わずにPromise自体をpropとして渡すのがポイントです。fetchはサーバーで開始され、クライアントではPromiseが届くのを待ち、届けばReactがSuspenseで処理してfallback ↔ コンテンツの入れ替えを自動的に処理します。

このパターンの利点はサーバーでfetchをすぐに開始できる点です。awaitしてからpropを渡すと、そのawaitの間Suspense fallbackが見えませんが、Promiseをそのまま渡すと、クライアントがSuspense境界の中でそれを解こうとした瞬間にfallbackが即座に表示されます。

シナリオ2. Contextを条件付きで使用 #

useContextは関数の最上位でのみ呼び出し可能でしたが (#13のフックルール)、use条件文の中でも呼び出し可能です。

条件付き context 使用
'use client';

import { use } from 'react';
import { ThemeContext } from './ThemeContext';

function Card({ showTheme }) {
  if (showTheme) {
    const theme = use(ThemeContext);  // 条件文の中で OK
    return <div className={theme}>...</div>;
  }
  return <div>...</div>;
}

これが可能な理由はuseが一般のフックと異なるメカニズムで動作するからです。日常的によく使うパターンではありませんが、知っておくと役立つときがあります。

注記
useはReact 19で正式に安定化された比較的新しいフックです。既存のuseContext/useStateのようなフックを置き換えるものではなく、PromiseやContextをより柔軟に扱う追加のツールと考えてください。一般的なデータフェッチはServer Componentでただawaitするのが最も簡単です。

動作確認 — 段階的ローディングサイト #

段階的ローディングの違いを直接感じてもらう例を作ってみましょう。

src/app/dashboard/page.js:

src/app/dashboard/page.js
import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div style={{ padding: '24px' }}>
      <h1>ダッシュボード</h1>
      <p>このページは複数の部分がそれぞれの速度でロードされます</p>

      <section style={{ marginTop: '24px' }}>
        <h2>プロフィール (速い)</h2>
        <Suspense fallback={<Skeleton text="プロフィール読み込み中..." />}>
          <Profile />
        </Suspense>
      </section>

      <section style={{ marginTop: '24px' }}>
        <h2>通知 (普通)</h2>
        <Suspense fallback={<Skeleton text="通知読み込み中..." />}>
          <Notifications />
        </Suspense>
      </section>

      <section style={{ marginTop: '24px' }}>
        <h2>アクティビティ記録 (遅い)</h2>
        <Suspense fallback={<Skeleton text="アクティビティ記録読み込み中..." />}>
          <Activity />
        </Suspense>
      </section>
    </div>
  );
}

function Skeleton({ text }) {
  return (
    <div style={{ padding: '12px', background: '#f4f4f4', color: '#888', borderRadius: '4px' }}>
      {text}
    </div>
  );
}

async function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function Profile() {
  await delay(500);
  return (
    <div style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px' }}>
      <strong>太郎</strong> · taro@example.com
    </div>
  );
}

async function Notifications() {
  await delay(2000);
  return (
    <ul style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px', listStyle: 'disc inside' }}>
      <li>新着メッセージ 3</li>
      <li>友達リクエスト 1</li>
    </ul>
  );
}

async function Activity() {
  await delay(4000);
  return (
    <ul style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '4px', listStyle: 'decimal inside' }}>
      <li>10分前  新規投稿</li>
      <li>1時間前  コメント追加</li>
      <li>3時間前  いいね</li>
    </ul>
  );
}

/dashboardに移動してみてください。

  • 0.5秒後にプロフィールが表示される (他の2エリアはまだローディング中)
  • 2秒後に通知が表示される
  • 4秒後にアクティビティ記録が表示される

各セクションが自分のペースで画面に現れます。1つのエリアが遅いからといって他のエリアまで待つことはありません。これがstreamingの実際の姿です。

ブラウザのネットワークタブでページリクエストを見ると、応答が一度で終わらずchunk単位で段階的に届くことも確認できます — サーバーが準備された部分から送っているわけです。

よくある落とし穴 #

1. ページ全体をawaitで包んでbuffer効果を消す #

🚫 streaming 効果なし
export default async function Page() {
  const profile = await getProfile();
  const posts = await getPosts();    // ここですべて待つ
  const stats = await getStats();

  return (
    <>
      <Profile data={profile} />
      <Suspense fallback={<p>ローディング...</p>}>
        <PostsSection data={posts} />     {/* すでに await 終了で fallback が見えない */}
      </Suspense>
    </>
  );
}

ページ関数ですべてのデータをawaitで取得した後に子にpropsで渡すと、ページ関数が終わるまでクライアントに何も送られずSuspenseの効果が出ません。awaitは子コンポーネントの中に移動してください。

2. Suspenseが小さすぎる単位に適用される #

🚫 細かすぎる
{posts.map(post => (
  <Suspense key={post.id} fallback={<p>ローディング...</p>}>
    <PostItem postId={post.id} />
  </Suspense>
))}

リストの各項目ごとに別々のSuspense境界を置くと、項目たちがバラバラにチラチラ表示されます。通常はリスト全体を1つのSuspenseに置く方が自然です。

3. Server Componentの中でuse(Promise)を呼び出す — 通常はただawait #

use(Promise)は主にPromiseをpropとして受け取ったClient Componentで意味のあるパターンです。Server Componentではただawaitの方が単純で明確です。

まとめ #

今回の記事では段階的ローディングを作るツールを扱いました。

  • Suspense — fallback ↔ コンテンツを自動的に入れ替える境界
  • Server Components + Suspense = streaming (準備された部分から送る)
  • loading.js — ページ単位の自動Suspense
  • Skeleton fallback — ジャンプのない滑らかな遷移
  • use()フック — Promise / Contextをより柔軟に扱う新しいツール

ここまで私たちはデータを読むだけでした。ユーザーがフォームを送信したり、ボタンを押してサーバーデータを変更することはどうやるのでしょうか。次の記事でありシリーズの最終回「モダンReact + Next.js #6 Server Actionsとフォーム」では、Next.jsの最も新しく強力なツールであるServer Actionsを扱います。これまで学んだすべてを合わせた小さなミニプロジェクトでシリーズを締めくくります。

X