Skip to content

research: Suspense の動作原理を整理#3

Open
miura-188cm wants to merge 1 commit into
mainfrom
research/suspense
Open

research: Suspense の動作原理を整理#3
miura-188cm wants to merge 1 commit into
mainfrom
research/suspense

Conversation

@miura-188cm
Copy link
Copy Markdown
Owner

@miura-188cm miura-188cm commented May 9, 2026

AI との研究結果

AIが書いた内容

Home.jsx<Suspense> を題材に、Suspense が何に反応する仕組みかを公式ドキュメントベースで整理した。

1. Suspense の本質

公式ドキュメントの記述:

"If children suspends while rendering, the Suspense boundary will switch to rendering fallback."

Suspense は 「Promise を監視している」のではなく「子の "suspend" という振る舞いに反応する」装置。Promise が pending であることを React が直接観測しているわけではない。

2. Suspense を起動できるのは 3 種類だけ

公式が明示している Suspense-enabled data sources:

"Only Suspense-enabled data sources will activate the Suspense component. They include:

  • Data fetching with Suspense-enabled frameworks like Relay and Next.js
  • Lazy-loading component code with lazy
  • Reading the value of a cached Promise with use"

つまり use だけではなく、React.lazy() や Suspense 対応 framework の fetch も起動できる。逆に、素の fetch().then(...)useEffect 内の Promise は Suspense と統合されない

"Suspense does not detect when data is fetched inside an Effect or event handler."

3. 内部機構: Promise の throw

use(promise)lazy() は内部で Promise を throw している だけ。コンポーネントから直接 throw promise しても全く同じ挙動になる。

function Foo() {
  throw new Promise((resolve) => setTimeout(resolve, 2000))
}
// → 2 秒間 fallback が表示される

これが Suspense の生の動作で、use / lazy はこの上にキャッシュと状態管理を被せた糖衣構文。

4. 「定義」と「throw」は責務が完全に違う

操作 役割 担当
const p = fetch(...) 非同期処理の 起動 JS ランタイム
throw p render の 中断と委譲 React Suspense

throw fetch(...) は「定義 + throw を 1 行で書いた」だけで、内部的には 2 ステップ。throw 自体は fetch を起こさない (起動済みの Promise を React に渡すだけ)。Promise オブジェクト自体は React のことを知らないし、React も世の中の Promise を監視しているわけではない。throw が両者を繋ぐ唯一の橋

例外との対比で整理すると綺麗:

const e = new Error("oh no")  // 定義しただけ。何も起きない
throw new Error("oh no")       // call stack を unwind する

Promise も同じで、定義は async work の起点、throw は control-flow の制御。

5. 落とし穴: キャッシュなしでは無限 suspend

function Foo() {
  throw fetch("/api")  // ❌ 毎 render で新 Promise → 永久に解決しない
}

正しくは Promise インスタンスを再利用 + 状態管理:

let status = "pending", result
const p = fetch("/api").then(r => { status = "fulfilled"; result = r })

function Foo() {
  if (status === "pending") throw p
  return <div>{result}</div>
}

use はこの状態管理を内蔵してくれるので、アプリコードでは直接 throw する必要はない。

6. transition との関係

公式ドキュメントの正確な記述:

"If Suspense was displaying content for the tree, but then it suspended again, the fallback will be shown again unless the update causing it was caused by startTransition or useDeferredValue."

"A Transition doesn't wait for all content to load. It only waits long enough to avoid hiding already revealed content."

「transition 中は fallback を出さない」は不正確で、正しくは 「既に表示されている内容を fallback で隠さない」 だけ。新規に作られた Suspense 境界は普通に fallback を出す。Home.jsx の「初回は fallback / 更新時はそのまま」の挙動はこの仕様の帰結。

7. なぜ throw Promise が選ばれたのか (設計史)

実は React チームが解きたかった問題は "algebraic effects (代数的効果) の代用品"。Sebastian Markbåge 自身が gist のタイトルを "Poor man's algebraic effects" と名付けている。

解きたかった問題:

「ツリーの深い場所で発生した非同期要求を、途中の関数を一切汚染せずに、上方の React に伝えたい」

Dan Abramov:

"some code below in the call stack yields to something above in the call stack (React, in this case) without all the intermediate functions necessarily knowing about it or being 'poisoned' by async or generators."

検討された代替案と却下理由:

代替案 却下理由
コンポーネントを async function にする 中間関数すべて async に汚染される。React の同期 render と非整合
Generator (yield) カスタム syntax が必要、ツールチェーン整備が現実的でない
コールバック / Observable loading 状態が "first class declarative concept" にならない
真の Algebraic Effects JS の言語機能として存在しない

throw が勝った 5 つの理由:

  1. 中間関数を汚染しない (non-poisoning): 通常の同期関数のままで深い階層から脱出できる唯一の既存メカニズム
  2. Error Boundary との対称性: 既に「throw された値を上位の境界が catch する」モデルがあった。Suspense Boundary はその対称物で、値が ErrorPromise かで挙動を分けるだけ。新規概念ゼロ
  3. 冪等性との相性: React の render はもともと「副作用なく何度も呼べる」前提。throw で abort して resolve 後に再実行しても、ユーザーから見れば resume と同じ
  4. ツールチェーン非依存: Babel やカスタム syntax 不要。React 16 時点で即出荷可能
  5. Fiber スケジューラとの親和性: 協調的スケジューラの「中断 → 再開」として自然に扱える

8. throw promise から use(promise) への進化

throw 方式の欠点: 「任意の関数から suspend できてしまう」。RFC #229:

"unstable mechanism allows arbitrary functions to suspend during render by throwing a promise"

そこで usethrow メカニズムを捨てたのではなく、throw できる場所をフックに限定する API レイヤを被せたもの。

throw promise (アプリコードで自由)        ← React 16-18 (hack)
    ↓
use(promise) (フックに throw を閉じ込める)  ← React 19 (規律化)

中身は同じで、規律化された API 表面を被せただけ。throw promise は internal implementation detail に降格、use が public API。書けば動くが、推奨されない。

9. SC と CC で API が違う理由

環境 components が async function? データ取得 suspend の起こし方
Server Component ✅ できる await fetchX() RSC ランタイムが自動で起こす
Client Component ❌ できない use(promise) 内部で throw して起こす

CC が async function にできない理由:

  1. async function は Promise を返す
  2. React の reconciler はコンポーネントを同期的に呼び出して JSX を期待する
  3. しかも CC は再 render が頻発する。await を跨ぐ render サイクルが無数にあって整合性が取れない

SC は再 render なし、線形に終わればよく、await の中断は JS ランタイムが面倒を見てくれるので普通に書ける。

つまり use の存在理由は、CC が async function になれないことを補うため

10. SC → CC 境界で Promise を渡す pattern

// SC (await しない)
async function Page() {
  const dataPromise = fetchData()       // Promise のまま渡す
  return (
    <Suspense fallback={<Skeleton />}>
      <ClientList promise={dataPromise} />
    </Suspense>
  )
}

// CC
'use client'
function ClientList({ promise }) {
  const data = use(promise)             // ここで suspend
  return <ul>{data.map(...)}</ul>
}

SC で await すると render 全体がブロックされる。Promise のまま渡せば SC は即時に JSX を返せて、CC が独自の Suspense 境界で待つ。ストリーミングの粒度を細かく制御するために use が活きる

11. Next.js loading.tsx の正体

ファイル規約による Suspense の自動配置:

// Next.js が自動で組み立てる構造
<Suspense fallback={<Loading />}>    {/* ← loading.tsx の出力 */}
  <Page />                            {/* ← page.tsx */}
</Suspense>

役割分担:

ファイル 役割 React 上の位置
page.tsx 本体コンテンツ <Suspense> の children
loading.tsx suspend 中の見た目 <Suspense fallback={...}> の fallback
Next.js この 2 つを <Suspense> でくるむ 境界の自動配置

重要な点:

  • loading.tsx は SC でも CC でもよい。fallback に必要なのは「インタラクティブ性」ではなく「JSX 値」だけ
  • Suspense 機構自体は React primitive で client React に内蔵されている。loading.tsx を CC 化する必要はない
  • page.tsx が "use client"use() で throw しても、最近接 Suspense (= loading.tsx を fallback とするやつ) が catch する

12. 最近接ルール (Suspense / Error Boundary 共通)

ネストしたとき:

<Suspense fallback={<Loading />}>          {/* loading.tsx (外側) */}
  <Page>
    <Header />                              {/* throw → 外側が catch */}
    <Suspense fallback={<LocalSkeleton />}>
      <DataList />                          {/* throw → 内側が catch */}
    </Suspense>
    <Footer />                              {/* throw → 外側が catch */}
  </Page>
</Suspense>

throw された値は call stack を unwind して 最初に出会った catch で捕まる。これは Error Boundary と完全に対称な挙動で、JS の throw/catch の挙動がそのまま React の境界に投影されている。

「優先順位」という独立したルールは存在せず、error.tsx も含めて App Router の境界は 規約ベースのファイル配置で React primitive を自動配線しているだけ

13. 一行まとめ

Suspense は "Promise を見る" のではなく "コンポーネントの suspend を捕捉する" 境界。
suspend を起こせるのは use / lazy / 対応 framework fetch の 3 つだけ。
render 中の出来事しか捕捉できないので、Effect や handler の Promise はノーカン。
throw Promise は "algebraic effects がない JS で同等のことをやる現実的なハック"。use はその思想を保ったまま、throw を hooks に閉じ込めた進化形。
SC は async/await が直接書ける、CC は use を使う。use の存在理由は CC が async function になれないことを補うため。
Next.js の loading.tsx は Suspense の fallback の中身を提供するファイル規約で、Suspense 自体は React primitive。

主要ソース

miura が書いた内容

throw Promise するのは非推奨
今後様式が変わるかもしれない.

黙って use を使うべき

use を 使えば,コンポーネント内部で loading とかを出す必要がなくなるし便利
コンポーネントがスッキリする
積極的に使うべし

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant