Suspenseの仕組み:Promiseをthrowする設計
<Suspense>がどのようにしてデータ待機中のUIを管理するのか。
「PromiseをthrowするとReactが受け取る」という設計の内側を解き明かします。
🔥 問題提起:非同期とコンポーネントの相性の悪さ
コンポーネントは「現在のstateとpropsを受け取り、UIを返す関数」です。しかし、データフェッチのような非同期処理を含む場合、 「データがまだ来ていない」という状態をどう表現するのかが問題になります。
従来のアプローチの問題点
// 典型的なfetch + useEffectパターン
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
// ローディング状態を各コンポーネントで個別に管理しなければならない
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
} - loading/error/dataの3状態管理がすべてのコンポーネントで重複する
- コンポーネントのロジックがデータフェッチの状態管理で汚染される
- ネストしたコンポーネントのローディング状態を統合しにくい
- ウォーターフォールフェッチ(順次フェッチ)が起きやすい
Suspenseが解決したいこと
「データが準備できていない」という状態を、コンポーネント内部で管理するのではなく、 コンポーネントツリーの構造として表現する。ローディングUIの責任をコンポーネントから切り離す。
🎯 結論から言う
SuspenseはPromiseをthrowすることでReactに「まだ準備できていない」を伝えます。
Reactがキャッチし、最も近い<Suspense>バウンダリのfallbackをレンダリングします。
🗺️ Promiseをthrowするとは何か
JavaScriptではthrowで任意の値を投げられます。
ReactはError Boundaryと同じ仕組みを使い、throwされた値がPromiseかどうかを判定して処理します。
// Suspense対応のキャッシュラッパーを実装する
function createResource(fetchFn) {
let status = 'pending';
let result;
// Promiseを即座に起動して結果をキャッシュ
const promise = fetchFn().then(
data => {
status = 'success';
result = data;
},
error => {
status = 'error';
result = error;
}
);
return {
read() {
if (status === 'pending') {
throw promise; // ← ここでPromiseをthrow!
} else if (status === 'error') {
throw result; // ← エラーはError Boundaryへ
} else {
return result; // ← 成功時はデータを返す
}
}
};
}
// 使い方
const userResource = createResource(() => fetchUser(userId));
function UserProfile() {
// データが未準備ならPromiseをthrow、準備できていればデータを返す
const user = userResource.read();
return <div>{user.name}</div>; // ここに来る時は必ずデータがある
} // packages/react-reconciler/src/ReactFiberThrow.js(概略)
function throwException(root, returnFiber, sourceFiber, value) {
// throwされた値がPromise(thenable)かどうか確認
if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {
// Suspense対応のwakeable(Promiseのこと)
const wakeable = value;
// 最も近いSuspenseバウンダリを探す
let workInProgress = returnFiber;
do {
if (workInProgress.tag === SuspenseComponent) {
// Suspenseバウンダリのfallbackをアクティベートするようフラグを立てる
workInProgress.flags |= ShouldCapture;
// Promise完了時に再レンダリングをスケジュール
wakeable.then(() => {
// retryDehydratedSuspenseBoundary相当の処理
scheduleUpdateOnFiber(root, workInProgress, SyncLane);
});
return;
}
workInProgress = workInProgress.return;
} while (workInProgress !== null);
}
// Promiseでなければ Error Boundary へ
} ⚠️ 重要な設計の注意
コンポーネントの中でthrow promiseを直接書いてはいけません。
これはライブラリ(ReactQuery、SWR、Relay等)またはReactのuseフックが行う処理です。
アプリケーションコードから直接throwするのはアンチパターンです。
🎭 fallbackのレンダリング:コンポーネントツリーとしての管理
<Suspense fallback={<Spinner />}>は、
子コンポーネントのいずれかがPromiseをthrowしたときにfallbackを表示します。
この「最も近い祖先のSuspenseバウンダリ」という設計がポイントです。
// ネストしたSuspenseバウンダリ
function App() {
return (
// 外側のバウンダリ:ページ全体のローディング
<Suspense fallback={<PageSkeleton />}>
<Header />
{/* 内側のバウンダリ:コンテンツだけのローディング */}
<Suspense fallback={<ContentSpinner />}>
<MainContent /> {/* ← throwすると内側のfallbackが表示 */}
<Sidebar /> {/* ← throwすると内側のfallbackが表示 */}
</Suspense>
<Footer />
</Suspense>
);
}
// MainContentがPromiseをthrowする場合:
// → 内側のSuspenseがキャッチ → ContentSpinnerを表示
// → HeaderとFooterは通常通りレンダリングされる Suspenseのフェーズ変化
import { SuspenseList } from 'react';
// SuspenseListで複数のSuspenseの表示順序・方式を制御
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<ProfileSkeleton />}>
<ProfileDetails />
</Suspense>
<Suspense fallback={<PostSkeleton />}>
<ProfileTimeline />
</Suspense>
<Suspense fallback={<PhotoSkeleton />}>
<ProfilePhotos />
</Suspense>
</SuspenseList>
// revealOrder="forwards": 上から順番に表示
// tail="collapsed": 最後の1つのfallbackだけ表示 ⚡ Concurrent ModeとSuspense:深い関係
SuspenseはConcurrent Modeなしでも動作しますが、Concurrent Modeと組み合わせることで Concurrent Features(startTransitionとの統合)が有効になります。
function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
return (
<>
<button onClick={() => {
startTransition(() => setTab('profile'));
// startTransitionを使うと...
// → profileタブのSuspenseが解決するまで
// homeタブのUIを維持する(古いUIを表示し続ける)
// → fallbackへの切り替えを避けられる!
}}>
プロフィールへ
</button>
{isPending && <div class="loading-bar" />}
<Suspense fallback={<Spinner />}>
{tab === 'home' && <HomeTab />}
{tab === 'profile' && <ProfileTab />}
</Suspense>
</>
);
}
// startTransitionなしだと:
// → タブを切り替えた瞬間にSpinnerが表示される(flickering)
// startTransitionありだと:
// → 新しいタブのデータが来るまでhomeUIを維持(smoother) Concurrent Modeが変えること
React 17以前(Legacy Mode)では、Suspenseがトリガーされると即座にfallbackに切り替わります。
React 18(Concurrent Mode)では、startTransitionと組み合わせることで
「古いUIを維持したまま、新しいコンテンツのロードを待つ」という動作が可能になります。
これを「Deferred Suspense」または「Selective Hydration」と呼びます。
📦 React.lazy:コードスプリットとSuspense
React.lazyはコードスプリットのためのAPIで、
Suspenseと組み合わせて使います。内部ではPromiseをthrowする仕組みを利用しています。
import { lazy, Suspense } from 'react';
// 動的インポートをラップ
const HeavyComponent = lazy(() => import('./HeavyComponent'));
// ↑ これはPromiseを返す関数をラップしたもの
function App() {
return (
<Suspense fallback={<div>読み込み中...</div>}>
<HeavyComponent />
{/* 初回レンダリング時、HeavyComponentのJSが未ロードなら
内部でPromiseをthrow → Suspenseがfallbackを表示
JSロード完了後 → 再レンダリングしてHeavyComponentを表示 */}
</Suspense>
);
}
// React.lazyの内部実装イメージ
function lazy(loadFn) {
let status = 'pending';
let result;
const promise = loadFn().then(
module => { status = 'fulfilled'; result = module.default; },
error => { status = 'rejected'; result = error; }
);
return function LazyComponent(props) {
if (status === 'pending') throw promise; // Suspenseへ
if (status === 'rejected') throw result; // Error Boundaryへ
return createElement(result, props); // 通常レンダリング
};
} 🪝 useフック:公式のSuspense統合API
React 19で安定化したuseフックは、
コンポーネント内でPromiseやContextを直接読み取るAPIです。
内部的にはPromiseをthrowするSuspenseの仕組みをReact公式がラップしたものです。
import { use, Suspense } from 'react';
// fetchUserはPromiseを返す
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
function UserProfile({ userId }) {
// useフックがPromiseを受け取り、
// - 未解決ならPromiseをthrow(Suspenseがキャッチ)
// - 解決済みならデータを返す
const user = use(fetchUser(userId));
return <div>{user.name}</div>;
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>
);
}
// ※ useフックはif文の中でも使える(通常のHooks規則の例外)
function ConditionalFetch({ userId, shouldFetch }) {
if (shouldFetch) {
const user = use(fetchUser(userId)); // OK!
return <div>{user.name}</div>;
}
return null;
} useフックとuseEffectフェッチの比較
📌 まとめ
- ✓ SuspenseはPromiseをthrowすることでReactに「まだ準備できていない」を伝える仕組み
- ✓ throwされたPromiseを最も近い祖先の<Suspense>がキャッチしてfallbackを表示する
- ✓ Promise解決後、Reactはコンポーネントを再レンダリングして実コンテンツを表示する
- ✓ Error BoundaryとSuspenseは同じ「throwをキャッチする」仕組みを使っている
- ✓ React.lazyも内部でPromiseをthrowしてSuspenseと連携する
- ✓ Concurrent ModeとstartTransitionを組み合わせると、フォールバックへの切り替えを抑制できる
- ✓ React 19のuseフックはSuspenseをアプリレベルで直接統合するための公式API