⚛️ React Boundary
発展 — ⏳ Suspense

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をレンダリングします。

1️⃣
コンポーネントがPromiseをthrow
データが未準備であることを通知
2️⃣
ReactがPromiseをキャッチ
fallbackに切り替えてPromise完了を待つ
3️⃣
Promise解決後に再レンダリング
fallbackを取り除き、実UIを表示

🗺️ Promiseをthrowするとは何か

JavaScriptではthrowで任意の値を投げられます。 ReactはError Boundaryと同じ仕組みを使い、throwされた値がPromiseかどうかを判定して処理します。

Suspense対応のデータフェッチライブラリの実装パターン
// 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>; // ここに来る時は必ずデータがある
}
React内部:Promiseをキャッチして処理する流れ(概略)
// 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バウンダリのネスト
// ネストした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のフェーズ変化

dehydrated → 子コンポーネントがPromiseをthrow中
fallback → fallbackをレンダリング中
revealed → Promiseが解決し、実コンテンツを表示
SuspenseList:複数のサスペンスを協調させる
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との統合)が有効になります。

React 18:startTransitionとSuspenseの組み合わせ
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する仕組みを利用しています。

React.lazyの使い方と内部動作
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公式がラップしたものです。

useフックによるデータフェッチ
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フェッチの比較

use フック
useEffect フェッチ
ローディング管理
Suspenseが担当
自分でstate管理
エラー管理
Error Boundary
自分でstate管理
ウォーターフォール
並列フェッチ可能
起きやすい
SSR対応
Server Components対応
SSRで使えない

📌 まとめ

  • ✓ 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

関連記事