Skip to content

4.3 Suspense と非同期データ ​

非同期データをコンポーネントの中で扱うとき、Part 3.6 までは useEffect で fetch して、useState にデータとローディングフラグを持たせる書き方をしてきました。React には、この「ローディング中かどうか」を 境界で 管理する仕組みがあります。それが <Suspense> です。最初は発想の転換が必要なところなので、順を追って見ていきましょう。

Suspense のメンタルモデル ​

一言で言うと、<Suspense> は「この中のどこかの子がデータ待ちで止まっているなら、全体の代わりに fallback を表示する」境界 です。

tsx
import { Suspense } from "react";

<Suspense fallback={<p>読み込み中...</p>}>
  <ChildThatMightSuspend />
</Suspense>

ここで大事な発想の転換があります。

  • useEffect 方式では、データを必要とするコンポーネント自身 が「ローディング中かどうか」を判定して、早期 return でスピナーを出す書き方になります。
  • Suspense 方式では、データを必要とするコンポーネントは「データが揃っている前提」で書く だけでよくて、ローディング表示は 外側の境界 が引き受けてくれます。

これによって、データを使う側のコードが条件分岐だらけにならずに済みます。

図 4.3.1 Suspense が引き受ける「境界」の考え方 ​

useEffect 方式<Page />if (isLoading) return <Spinner/><Posts />if (isLoading) return <Spinner/><Post />if (isLoading) ...Suspense 方式<Suspense fallback=Loading><Page />データが来ている前提でよい<Posts /><Post />条件分岐が不要

どうやって「止まる」のか ​

コンポーネントが「データ待ち」であることを React に伝える方法は、2026 年時点では次の 2 つに集約されます。

  1. use(promise) を呼んで、その Promise がまだ resolve していない場合(React 19 で公式に入った方法、4.2 参照)
  2. React.lazy で読み込まれたコンポーネントのバンドルがまだ読み込めていない場合

これ以外にも、フレームワーク(Next.js、Relay など)が Suspense 対応の独自データソースを提供していれば、それ経由でも suspend します。Suspense をトリガーする「Suspense-enabled data source」は、React 公式ドキュメントで use / lazy / フレームワーク提供のデータ取得 API に限られる と明示されています(アプリケーション側で直接実装することは想定されていません)。

一方、useEffect の中で fetch してデータを useState に入れる方式は Suspense をトリガーしません。Suspense の恩恵を受けたいときは、use(promise) かフレームワーク側のデータ取得関数を使うのが前提になります。

基本形: use(promise) + Suspense ​

tsx
// react.dev/reference/react/Suspense の例を翻案
import { Suspense, use } from "react";

function Albums({ promise }: { promise: Promise<{ id: string; title: string }[]> }) {
  const albums = use(promise); // ← ここで suspend する可能性
  return (
    <ul>
      {albums.map((a) => (
        <li key={a.id}>{a.title}</li>
      ))}
    </ul>
  );
}

export function ArtistPage({ albumsPromise }: { albumsPromise: Promise<{ id: string; title: string }[]> }) {
  return (
    <Suspense fallback={<p>アルバムを読み込み中...</p>}>
      <Albums promise={albumsPromise} />
    </Suspense>
  );
}

この albumsPromise は Server Component で作って props として渡すのが、公式が勧めるパターンです(Part 5 で詳しく扱います)。

React.lazy でコード分割 ​

コンポーネントをバンドルから切り離して、必要になった時点で動的に import する仕組みが React.lazy です。

tsx
// react.dev/reference/react/lazy より翻案
import { lazy, Suspense, useState } from "react";

const MarkdownPreview = lazy(() => import("./MarkdownPreview"));

export function Editor() {
  const [preview, setPreview] = useState(false);
  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={preview}
          onChange={(e) => setPreview(e.target.checked)}
        />
        プレビューを表示
      </label>
      {preview && (
        <Suspense fallback={<p>プレビューを読み込み中...</p>}>
          <MarkdownPreview />
        </Suspense>
      )}
    </>
  );
}

lazy は 1 回しか load 関数を呼ばず、結果をキャッシュしてくれます。宣言は 必ずモジュールのトップレベル で行ってください(コンポーネントの中で lazy(...) すると毎回別物になり、毎回読み込み直しになってしまいます)。

Error Boundary とセットで覚える ​

Suspense は「データ待ち」だけを扱います。Promise が reject したとき(エラーのとき) は、最も近い Error Boundary の fallback に切り替わります。つまり、非同期データを扱うコンポーネントは Suspense と Error Boundary の 両方 で包むのが定石です。

React には組み込みの <ErrorBoundary> コンポーネントは提供されていません。公式は「クラスコンポーネントに componentDidCatch / getDerivedStateFromError を実装する」方法を案内していて、実務では薄いラッパーである react-error-boundary パッケージを使うのが一般的です。下のコードは react-error-boundary を使う前提で書いています。

tsx
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary"; // 組み込みではないので別途インストールが必要

<ErrorBoundary fallback={<p>エラーが発生しました</p>}>
  <Suspense fallback={<p>読み込み中...</p>}>
    <Albums promise={promise} />
  </Suspense>
</ErrorBoundary>;

図 4.3.2 Suspense と Error Boundary の役割分担 ​

ネストと段階的なローディング ​

Suspense は入れ子にできます。外側で粗く、内側で細かく fallback を設定することで、読み込み完了したところから順に表示 できるようになります。

tsx
<Suspense fallback={<p>ページ全体を読み込み中...</p>}>
  <Profile />
  <Suspense fallback={<p>投稿一覧を読み込み中...</p>}>
    <Posts />
  </Suspense>
</Suspense>

Profile が先に resolve すれば、Posts がまだでも Profile は見えます。Posts が resolve したら Posts も見えます。

触って確かめる — 境界とエラーの振り分け ​

下のデモは、「両方読み込み」と「Posts を失敗させる」のボタンで、Suspense と Error Boundary の役割分担を観察できる模型です。Profile は 900ms、Posts は 1800ms の疑似遅延があり、Posts を失敗させると外側の Error Boundary 相当の領域に切り替わります。

<ErrorBoundary fallback=エラー表示 >
<Suspense fallback=ページ読み込み中... >
<Profile />(ボタンで開始)
<Suspense fallback=投稿読み込み中... >
<Posts />(ボタンで開始)

Profile が先に resolve すれば、Posts がまだ読み込み中でも Profile は見えます。Posts を失敗させると、最も近い Suspense ではなく外側の ErrorBoundary に伝播します。このデモは挙動を真似た模型です (実際に use(promise) を呼んでいるわけではありません)。

すでに表示されている UI を消さずに更新する ​

ページ遷移やタブ切り替えで、Suspense の fallback が毎回現れると目障りに感じられます。この場合は startTransition / useTransition でラップすることで、すでに見えているコンテンツは残したまま、裏で新しいコンテンツを準備 できます。

tsx
import { useState, useTransition } from "react";

const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState("home");

function select(next: string) {
  startTransition(() => {
    setTab(next);
  });
}

Transition の中で起きた更新は、Suspense 境界がすでにコンテンツを表示している場合、既存の UI を表示したまま 新しい UI を裏で準備してくれます(isPending で間を埋める表示が可能です)。

クライアント側での主な使いどころ ​

  • React.lazy + <Suspense> によるルート単位のコード分割
  • フレームワーク(Next.js App Router など)が提供する、Suspense 対応のデータ取得関数
  • use(clientSidePromise) をクライアント側で使うケース(ただし、毎レンダーで新しい Promise を作らないよう注意。通常は Server Component で Promise を作ってクライアントに props として渡します)

Suspense がカバーしないもの ​

公式ドキュメント「Caveats」で明記されている点を押さえておきましょう。

  • useEffect の中で fetch した結果は Suspense で検出されません。
  • イベントハンドラの中で発生させた非同期処理も検出されません。
  • Suspense 対応のデータソース(use(promise)、lazy、フレームワーク提供)からのみトリガーされます。

動かして確かめる ​

React.lazy + <Suspense> の最小例を StackBlitz で試せます。

StackBlitz で開く

src/App.tsx に上記 Editor / MarkdownPreview 相当のコードを書き、チェックボックスを切り替えた瞬間に fallback が見えることを確認してください。

参照ソース ​