4.3 Suspense と非同期データ
非同期データをコンポーネントの中で扱うとき、Part 3.6 までは useEffect で fetch して、useState にデータとローディングフラグを持たせる書き方をしてきました。React には、この「ローディング中かどうか」を 境界で 管理する仕組みがあります。それが <Suspense> です。最初は発想の転換が必要なところなので、順を追って見ていきましょう。
Suspense のメンタルモデル
一言で言うと、<Suspense> は「この中のどこかの子がデータ待ちで止まっているなら、全体の代わりに fallback を表示する」境界 です。
import { Suspense } from "react";
<Suspense fallback={<p>読み込み中...</p>}>
<ChildThatMightSuspend />
</Suspense>ここで大事な発想の転換があります。
useEffect方式では、データを必要とするコンポーネント自身 が「ローディング中かどうか」を判定して、早期 return でスピナーを出す書き方になります。- Suspense 方式では、データを必要とするコンポーネントは「データが揃っている前提」で書く だけでよくて、ローディング表示は 外側の境界 が引き受けてくれます。
これによって、データを使う側のコードが条件分岐だらけにならずに済みます。
図 4.3.1 Suspense が引き受ける「境界」の考え方
どうやって「止まる」のか
コンポーネントが「データ待ち」であることを React に伝える方法は、2026 年時点では次の 2 つに集約されます。
use(promise)を呼んで、その Promise がまだ resolve していない場合(React 19 で公式に入った方法、4.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
// 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 です。
// 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 を使う前提で書いています。
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 を設定することで、読み込み完了したところから順に表示 できるようになります。
<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 相当の領域に切り替わります。
Profile が先に resolve すれば、Posts がまだ読み込み中でも Profile は見えます。Posts を失敗させると、最も近い Suspense ではなく外側の ErrorBoundary に伝播します。このデモは挙動を真似た模型です (実際に use(promise) を呼んでいるわけではありません)。
すでに表示されている UI を消さずに更新する
ページ遷移やタブ切り替えで、Suspense の fallback が毎回現れると目障りに感じられます。この場合は startTransition / useTransition でラップすることで、すでに見えているコンテンツは残したまま、裏で新しいコンテンツを準備 できます。
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 で試せます。
src/App.tsx に上記 Editor / MarkdownPreview 相当のコードを書き、チェックボックスを切り替えた瞬間に fallback が見えることを確認してください。