「Suspense でデータフェッチを実装しよう」と調べ始めたものの、出てくる記事によって書き方がバラバラだった、という経験はないでしょうか。ある記事では wrapPromise というラッパーを自作して read() で Promise を throw し、別の記事では use() というフックに Promise を渡すだけで済ませている。どちらも「Suspense でデータフェッチ」と銘打っているのに、コードがまるで違うのです。
この混乱には理由があります。React のデータフェッチ向け Suspense は長らく「正式 API のない実験的な領域」で、コミュニティが throw promise という回避策を発明して回していました。そして React 19 で use という第一級 API が安定版に入り、状況が一変しました。つまりネット上には「React 18 までの回避策」と「React 19 の正解」が時系列の区別なく並んでいる状態で、初見では「今どっちを書くべきか」が判断できないのです。
本記事では、Suspense の仕組みをいったん整理した上で、2026年の React 19 環境で採用すべきデータフェッチパターンを、動かす前提のコードと判断基準つきで解説します。あわせて、use() で最も多くのエンジニアが踏む「再レンダリングのたびに再フェッチが走り Suspense が無限ループする」落とし穴と、その回避基準も具体的に示します。レガシーな書き方を避け、本番で安全に動くコードを自信を持って書けるようになることがゴールです。
なお、Suspense の API そのものの正式な仕様はReact公式ドキュメントの <Suspense> と use を一次情報として確認することをおすすめします。本記事は、それらをどう組み合わせて実務で使うかという「判断の部分」を補完する位置づけです。
React Suspenseとは何か

React Suspense は、コンポーネントが「まだ表示できない状態」のときに、代わりのローディング UI(fallback)を自動で出してくれる仕組みです。データの取得やコードの遅延読み込みなど、非同期処理が終わるまでの「待ち時間の表示」を、状態変数や条件分岐を書かずに宣言的に扱えるようにするのが目的です。
使い方の骨格はとてもシンプルで、待たせたいコンポーネントを <Suspense> で囲み、fallback に読み込み中の表示を渡すだけです。
import { Suspense } from "react";
function Page() {
return (
<Suspense fallback={<p>読み込み中…</p>}>
<UserProfile />
</Suspense>
);
}
UserProfile の中身が「まだ準備できていない」とき、React は UserProfile の描画を一時中断(サスペンド)し、最も近い親の <Suspense> の fallback を表示します。準備が整うと、fallback は自動的に本来のコンテンツに置き換わります。開発者がローディング状態のフラグを管理したり、if (isLoading) return ... のような分岐を書いたりする必要はありません。
ここで重要なのは、「準備できていない」をコンポーネントから React に伝える方法です。この伝達方法こそが、React 18 までと React 19 で大きく変わった部分であり、ネット上の記事が食い違う原因でもあります。
React.lazyによるコード分割は今も変わらない
混乱しやすいので先に切り分けておくと、Suspense には「データフェッチ用途」と「コード分割(遅延読み込み)用途」の2つがあります。このうちコード分割用途は React 18 でも React 19 でも書き方が変わっていません。React.lazy() で動的インポートしたコンポーネントを <Suspense> で囲む、という従来どおりのパターンがそのまま現役です。
import { Suspense, lazy } from "react";
const HeavyChart = lazy(() => import("./HeavyChart"));
function Dashboard() {
return (
<Suspense fallback={<p>グラフを読み込み中…</p>}>
<HeavyChart />
</Suspense>
);
}
書き方が変わって混乱を招いているのは、もう一方の「データフェッチ用途」です。本記事が主に扱うのもこちらになります。コード分割については React.lazy + Suspense という型を覚えておけば、バージョンによる迷いはありません。
React 18までの「throw promise」方式がレガシーになった理由
React 18 までは、データフェッチを Suspense に対応させる公式 API がありませんでした。そこでコミュニティが使っていたのが、wrapPromise のようなラッパーを自作し、データが未解決なら Promise そのものを throw する、という回避策です。次のようなコードを見たことがあるかもしれません。
// React 18時代の回避策(現在は非推奨)
function wrapPromise(promise) {
let status = "pending";
let result;
const suspender = promise.then(
(r) => { status = "success"; result = r; },
(e) => { status = "error"; result = e; }
);
return {
read() {
if (status === "pending") throw suspender; // ← Promiseをthrowする
if (status === "error") throw result;
return result;
},
};
}
この read() を描画中に呼ぶと、未解決時は Promise が throw され、React がそれを捕まえてサスペンドする、という仕組みです。動作はしますが、これはあくまで「公式 API がない時代の回避策」でした。throw を制御フローに使うため挙動が直感的でなく、状態管理・エラー処理・再フェッチをすべて自前のラッパーで面倒見る必要があり、ボイラープレートが増えていきます。
React 19 では、この throw promise 方式を置き換える use という正式 API が安定版に入りました(React公式 use ドキュメント)。そのため、2026年時点で新規に Suspense 対応のデータフェッチを書くなら、wrapPromise/read()/手動 throw 方式を採用する理由はほぼありません。既存コードの保守でこの方式に出会うことはありますが、新しく書くコードでは use() を第一選択にする、というのが現在の判断基準です。
React 19のuse()フックで書く正しいデータフェッチ

React 19 で安定版に入った use は、Promise(やコンテキスト)の値をコンポーネントの描画中に直接読み取れる API です。公式ドキュメントは「use を Promise とともに呼び出すと、Suspense と Error Boundary に統合される。use を呼ぶコンポーネントは、渡された Promise が pending の間サスペンドする」と説明しています(React公式 use ドキュメント)。
つまり、自作の wrapPromise も read() も throw も不要になり、Promise を use() に渡すだけで Suspense が成立します。最も基本的な形は次のとおりです。
import { use, Suspense } from "react";
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // Promiseが解決するまでサスペンド
return (
<div>
<h4>ようこそ、{user.firstName} さん</h4>
<p>Email: {user.email}</p>
</div>
);
}
function Page({ userPromise }: { userPromise: Promise<User> }) {
return (
<Suspense fallback={<p>ユーザー情報を取得中…</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}
注目してほしいのは、UserProfile が Promise をプロパティとして受け取っている点です。後述する無限ループの落とし穴を避けるための最重要ポイントで、「Promise はコンポーネントの外で作り、use() するコンポーネントには渡すだけ」というのが現行の正解パターンの基本形です。
サーバーコンポーネントではuse()よりasync/awaitを優先する
判断を分ける最初の分岐は「サーバーコンポーネントか、クライアントコンポーネントか」です。Next.js の App Router など React Server Components(RSC)を使う環境では、データフェッチはサーバーコンポーネント側で async/await を使うのが第一選択になります。公式ドキュメントも「サーバーコンポーネントでデータを取得する場合は、use より async と await を優先する」と明記しています(React公式 use ドキュメント)。
// サーバーコンポーネント: async/awaitを直接使える
async function UserProfile({ userId }: { userId: string }) {
const user = await fetchUser(userId);
return <h4>ようこそ、{user.firstName} さん</h4>;
}
use() の出番は、「サーバーで取得を開始した Promise を、クライアントコンポーネントに渡して読む」ケースです。公式は「サーバーコンポーネントで Promise を作り、クライアントコンポーネントに渡すことを、クライアントコンポーネントで Promise を作るより優先する」と推奨しています(React公式 use ドキュメント)。これは見た目の好みではなく、後述する再レンダリング時の安定性に直結する、本番運用上の判断基準です。
// サーバーコンポーネント: awaitせずPromiseのまま渡す
function Page({ userId }: { userId: string }) {
const userPromise = fetchUser(userId); // awaitしない
return (
<Suspense fallback={<p>取得中…</p>}>
<UserProfile userPromise={userPromise} /> {/* クライアントコンポーネント */}
</Suspense>
);
}
エラーはError Boundaryで一元的に受け止める
use() に渡した Promise が reject した場合、エラーは最も近い Error Boundary に伝播します。try/catch をコンポーネント内に書く必要はなく、<Suspense> の外側を Error Boundary で囲んでおけば、ローディング(Suspense)と失敗(Error Boundary)の両方を宣言的にまとめられます。
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
function Page({ userPromise }) {
return (
<ErrorBoundary fallback={<p>読み込みに失敗しました</p>}>
<Suspense fallback={<p>取得中…</p>}>
<UserProfile userPromise={userPromise} />
</Suspense>
</ErrorBoundary>
);
}
「成功は Suspense、失敗は Error Boundary」という責務分担を押さえておくと、use() を使ったデータフェッチのエラー処理は迷いません。なお Error Boundary は標準では公式のクラスコンポーネント実装か、react-error-boundary のような定番ライブラリを使うのが一般的です。
use()でハマる無限ループとその回避基準

use() を使い始めたエンジニアが最も多く踏む落とし穴が、「再レンダリングのたびにフェッチが走り、Suspense が無限に繰り返される」現象です。先ほど「Promise はコンポーネントの外で作る」と強調したのは、まさにこの問題を避けるためです。
なぜ無限ループが起きるのか
原因は、Promise をコンポーネントの描画中(render 内)で生成してしまうことです。次のコードは一見動きそうに見えますが、本番で無限ループを引き起こします。
// アンチパターン: 描画のたびに新しいPromiseが生成される
function UserProfile({ userId }: { userId: string }) {
const user = use(fetchUser(userId)); // ← 毎回新しいPromise
return <h4>{user.firstName}</h4>;
}
use(fetchUser(userId)) は、UserProfile が描画されるたびに fetchUser() を呼び、毎回まったく別の Promise を生成します。React は「新しい pending な Promise」を見るたびにサスペンドし、解決して再描画し、再描画でまた新しい Promise を作ってサスペンドし……というループに陥ります。公式ドキュメントも「クライアントコンポーネントで作られた Promise は再描画のたびに作り直される。サーバーコンポーネントから渡された Promise は再描画をまたいで安定している」と注意を促しています(React公式 use ドキュメント)。これが、use() を扱う際に多くのエンジニアが現実に遭遇する最大の問題だと指摘されています(React 19 use() Hook: Data Fetching Patterns That Actually Work, SitePoint)。
回避基準1: Promiseはコンポーネントの外(または上)で作る
最もシンプルで確実な回避策は、「use() するコンポーネントの中では Promise を作らない」というルールを徹底することです。前章で示したとおり、Promise は親(サーバーコンポーネントやデータ取得を担う上位コンポーネント)で生成し、use() するコンポーネントにはプロパティとして渡すだけにします。この形にしておけば、use() するコンポーネントが再描画されても、受け取る Promise は同一インスタンスのままなので、無限ループは起きません。
判断基準はシンプルです。「use() に渡す Promise が、そのコンポーネントの描画ごとに新しく作られていないか」を確認してください。新しく作られているなら、その Promise の生成箇所を1つ上のレイヤーに引き上げます。
回避基準2: クライアント完結ならMapで Promiseをキャッシュする
サーバーコンポーネントを使えない構成(純粋な SPA など)で、どうしてもクライアント側で Promise を生成する必要がある場合は、キャッシュを挟みます。引数(依存値)をキーにして Promise を Map に保存し、同じキーなら同じ Promise を返すようにすれば、再描画のたびに新しい Promise が作られることを防げます(SitePoint)。
const userCache = new Map<string, Promise<User>>();
function getUserPromise(userId: string) {
if (!userCache.has(userId)) {
userCache.set(userId, fetchUser(userId)); // 初回のみ生成
}
return userCache.get(userId)!;
}
function UserProfile({ userId }: { userId: string }) {
const user = use(getUserPromise(userId)); // 同じuserIdなら同じPromise
return <h4>{user.firstName}</h4>;
}
ただし、自前の Map キャッシュはキーの設計・無効化(再取得したいときの破棄)・メモリ管理を自分で面倒見る必要があります。実務では、この役割を担うのがデータフェッチライブラリです。
回避基準3: ライブラリのSuspense連携に任せる
多くのプロジェクトでは、Promise のキャッシュや再取得・無効化を自前で実装するより、TanStack Query(旧 React Query)や SWR の Suspense モードに任せるほうが堅実です。これらはキャッシュキーの管理・再フェッチ・無効化を担ってくれるため、use() のために自前キャッシュを書くより安全で、無限ループの心配もありません。
import { useSuspenseQuery } from "@tanstack/react-query";
function UserProfile({ userId }: { userId: string }) {
const { data: user } = useSuspenseQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
return <h4>{user.firstName}</h4>;
}
判断の目安をまとめると、サーバーコンポーネントが使える環境なら「親で Promise を作って渡す(回避基準1)」、クライアント完結で軽量に済ませたいなら「Map キャッシュ(回避基準2)」、キャッシュや再取得を本格的に扱うなら「データフェッチライブラリの Suspense モード(回避基準3)」、という順で検討するのが実務的です。
Concurrent Featuresと組み合わせる実践パターン

React 18 以降の Concurrent Features(並行レンダリング)は Suspense と密接に連携しています。use() でデータフェッチを宣言的に書けるようになっても、「画面の切り替え時に既存の表示が一瞬 fallback に置き換わってチラつく」といった体験面の問題は残ります。ここを整えるのが startTransition / useTransition です。
startTransitionで「fallbackへの逆戻り」を防ぐ
たとえばタブを切り替えて新しいデータを取得する場面で、何も対策しないと、すでに表示されていたコンテンツが消えてローディング表示に戻ってしまいます。状態更新を startTransition(またはフック版の useTransition)でラップすると、React はこの更新を「緊急でない遷移」として扱い、新しいコンテンツの準備が整うまで古い表示を維持します。
import { useTransition, useState } from "react";
function Tabs() {
const [tab, setTab] = useState("home");
const [isPending, startTransition] = useTransition();
function selectTab(next: string) {
startTransition(() => {
setTab(next); // この更新によるサスペンドはfallbackに戻さない
});
}
return (
<>
<nav style={{ opacity: isPending ? 0.6 : 1 }}>
{/* タブボタン群 */}
</nav>
<Suspense fallback={<p>読み込み中…</p>}>
<TabContent tab={tab} />
</Suspense>
</>
);
}
isPending を使えば「遷移中である」ことを淡い表示などで伝えつつ、画面のチラつきを抑えられます。「初回表示は Suspense の fallback、表示後の切り替えは startTransition で滑らかに」という使い分けが実践的な判断基準です。
Streaming SSRとの連携で表示開始を早める
サーバーサイドレンダリングを行う環境では、Suspense は Streaming SSR と組み合わさります。ページ全体のデータが揃うのを待たずに、準備できた部分から順に HTML をクライアントへ送り、<Suspense> で囲んだ部分は後から差し込む、という挙動です。これにより、重いデータ取得を含むページでも「まず表示を始め、遅い部分は追って埋める」体験を実現できます。Next.js の App Router などフレームワーク側がこの仕組みを前提に作られているため、多くの場合は <Suspense> で境界を切るだけで恩恵を受けられます。
ここでも判断基準は変わりません。サーバー側で取得を開始した Promise をクライアントコンポーネントへ渡し、use() で読む、という現行パターンに乗っておけば、Streaming SSR とも自然に噛み合います。逆に、クライアントの描画中に Promise を作る旧来のやり方は、こうした連携でも不安定さの原因になります。
まとめ
React Suspense は、非同期処理中の「待ちの表示」を fallback で宣言的に制御する仕組みです。コード分割(React.lazy + Suspense)はバージョンを問わず変わりませんが、データフェッチ用途は React 19 で大きく変わりました。
2026年時点の判断基準を整理すると、次のとおりです。
- 新規実装で
wrapPromise/read()/手動throw promise方式を選ぶ理由はもうない。React 19 ではuse()が正式 API として置き換える - サーバーコンポーネントが使えるなら、データフェッチは
async/awaitを優先し、use()は「サーバーで作った Promise をクライアントで読む」用途に使う use()の最大の落とし穴は、描画中に Promise を作ることで起きる無限ループ。Promise は親で作って渡す/Mapでキャッシュする/TanStack Query や SWR の Suspense モードに任せる、のいずれかで回避する- 表示後の画面切り替えは
startTransitionでチラつきを抑え、SSR 環境では Streaming SSR と組み合わせて表示開始を早める
レガシーな書き方と現行の正解を切り分け、Promise の生成場所という一点に注意を払えば、Suspense を使ったデータフェッチは本番でも安全に扱えます。迷ったときは Promise の生成箇所を確認する、という基準を持ち帰っていただければ幸いです。


