目次
26 章

Suspense と use() でローディング処理

Suspense の境界モデルで streaming レンダリングを作り、`loading.tsx`・skeleton fallback・React 19 で安定化された `use()` フックまで整理します。

第25章で Server Component のデータフェッチが単純になったのを見ました。ところがこれまで作ったページには すべてのデータが届くまで画面が見えない という問題が残っています。ページの中に速いデータと遅いデータが混じっていても、最も遅い側に合わせて全員が待つ形です。

本章ではその問題を解く道具 — <Suspense>loading.tsx、そして React 19 で正式に安定化された use() フックを整理します。本章の streaming モデルは第31章(Web Vitals とパフォーマンス)で LCP / TTFB との関係でもう一度出会うことになります。use() は第28章(React 19 新機能まとめ)のビルディングブロックの 1 つでもあります。

問題 — 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 に短縮されます。データが届くまでの時間そのものは変わりませんが 体感速度 は劇的に良くなります。第31章(Web Vitals)で見ると LCP(Largest Contentful Paint)と TTFB(Time To First Byte)が同時に改善される構造です。

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

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

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

src/app/posts/loading.tsx:

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

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

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

Suspense 境界をどこに置くか #

Suspense を効果的に使うには 境界をどこに引くか の感覚が必要です。意思決定ガイド。

  1. 速いデータと遅いデータは異なる Suspense に置く。遅い側が速い側を隠さないように。
  2. ユーザー体験上、一緒に見えるべき自然な部分は同じ Suspense に。例: 記事のタイトルと著者。
  3. 細かく分けすぎない。すべての小さな部分に fallback を置くと、画面がちらつきのパッチワークになります。
  4. インタラクションの単位で切る。ユーザーが 1 つの塊として認識するカード / セクションは 1 つの Suspense の中に。画面を埋める大きな領域(サイドバー、メイン、フッター)は普通は別の Suspense。

Skeleton fallback #

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

src/app/posts/loading.tsx (skeleton)
function Skeleton({ width, height }: { width: string; height: string }) {
  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.tsx (Server)
import { Suspense } from 'react';
import PostList from './PostList';

type Post = { id: number; title: string };

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

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

import { use } from 'react';

type Post = { id: number; title: string };

export default function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {
  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 ↔ コンテンツの入れ替えを自動処理します。

このパターンは第24章で見た 「props はシリアライズ可能でなければならない」 の一例です — Promise はシリアライズ可能な値だからです。第24章の表で「Promise(第26章 use() と組)」の行を覚えていれば、本章でその意味が解けたことになります。

このパターンの利点は サーバーで 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 }: { showTheme: boolean }) {
  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.tsx:

src/app/dashboard/page.tsx
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 }: { text: string }) {
  return (
    <div style={{ padding: '12px', background: '#f4f4f4', color: '#888', borderRadius: '4px' }}>
      {text}
    </div>
  );
}

function delay(ms: number) {
  return new Promise<void>(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 の実際の姿です。

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

よくある落とし穴 #

1. ページ全体を await で包んで streaming 効果をなくす #

🚫 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 の方が単純で明確です。

練習問題 #

  1. streaming を直接観察する。上のダッシュボード例で dev tools の Network タブを開いて /dashboard 応答を調べてみてください。応答ヘッダの Transfer-Encoding: chunked と応答ボディが時間とともに追加されることを確認します。その後ページ関数の最初の行に const all = await Promise.all([profile(), notifications(), activity()]) のようにすべてのデータを先に await するよう変えて、streaming が消えて応答自体が遅くなることを比較してみてください。
  2. use() パターンを自分で作る。Server Component ページで Promise<Post[]> を作って Client Component の子に prop として渡し、子で use(postsPromise) で解いてレンダリングしてください。このときページで await postsPromise を先にしてから結果だけを渡すバージョンと比較して、Suspense fallback が見える時点がどう変わるかを観察します。
  3. Suspense 境界の設計。1 つのページに (a) ユーザーヘッダー(50ms)、(b) 推薦カード 3 つ(各 200ms)、(c) 活動フィード(1500ms)を表示しなければなりません。どの部分を同じ <Suspense> に置き、どの部分を分離するか、本文の 4 つのガイドラインに照らして設計し、そう決めた理由を 1 段落で書いてみてください。正解は 1 つではありません。トレードオフを説明できればよいです。

一行まとめ: <Suspense> は fallback ↔ コンテンツを自動で入れ替える境界で、Server Component の async 関数と組み合わさると 準備できた部分から送る streaming になる。ページ単位の fallback は loading.tsx 一枚で解決し、精密な分離は <Suspense> を直接置く。skeleton fallback でジャンプのない遷移を、React 19 の use() フックで Server が作った Promise を Client で解いて使う。await をページ関数の最上位で行うと streaming が消えるので、await は子コンポーネントの中に。

次の章 #

これまで我々は データを読むだけ でした。次の 第27章 Server Actions とフォームでは、ユーザーがフォームを送信したりボタンを押して サーバーデータを変更する mutation の新しいパラダイムを扱います。API ルートを一行も作らずにサーバー関数をそのまま呼び出す Server Actions、React 19 の useActionState / useFormStatus、そして本章の Suspense + 第27章の Server Actions を組み合わせた小さなミニプロジェクトで第4部を締めくくります。

X