the throw-promise mechanism
A component throws a promise during render when the data it needs
isn't ready. React catches the thrown promise, walks UP the tree to the nearest
<Suspense>, and renders its fallback instead. When the promise
resolves, React re-renders the suspended subtree — now the data is there, so read()
returns it and the real UI paints. No isLoading flags, no useEffect+setState,
no race conditions: the loading state is a property of the tree, not a field you manage.
| API | what it suspends on | resolves when |
|---|---|---|
React.lazy(() => import()) |
a component module | the dynamic import() promise settles |
use(promise) (React 19) |
a Promise (or Context) | the awaited promise resolves |
resource.read() (this demo) |
any cached promise | the wrapper flips status to 'success' |
<Suspense fallback> |
— (the catcher, not the thrower) | the nearest child stops suspending |
1 · the resource cache you write (edit me)
2 · Babel compiles JSX → element tree
<Suspense fallback={...}> becomes
React.createElement(Suspense, {fallback: ...}). The thrown promise is
invisible at the JSX level — it lives inside resource.read(), which Babel
leaves untouched as a plain function call.
// (hit "compile & render" to see Babel's output)
3 · live React (the throw-promise round-trip, proven)
On mount, DataViewer calls resource.read() — the promise is pending,
so it throws. Suspense catches it and renders the Loading… fallback. ~600ms later the
promise resolves, React retries, and Loaded: Hello Suspense! paints. Click
Refetch to swap in a fresh resource and watch the round-trip again.
The gold-check automates all of this.
state: — · gold: fallback → "Hello Suspense!" → Refetch → "Reload #2 OK"
intent → Suspense pattern
| intent | pattern | why |
|---|---|---|
| lazy-load a component | const C = lazy(() => import('./C'))<Suspense fallback={...}><C/></Suspense> |
suspends until the chunk downloads; no waterfall on route change |
| read a promise during render | const data = use(promise) |
React 19 native; throws the promise, Suspense catches — no manual cache needed if the promise is cached upstream |
| avoid refetch on every render | cache the promise (useState(() => createResource(fetch()))) |
Suspense re-renders the subtree; an uncached promise re-suspends forever |
| isolate loading regions | nest <Suspense> |
the nearest ancestor's fallback wins; outer content stays interactive while a child loads |
| handle fetch errors | wrap <ErrorBoundary> around <Suspense> |
a thrown error from a suspended component bypasses Suspense and bubbles to the boundary |
| keep showing old data while refetching | useTransition + <Suspense> |
the transition holds the old UI until the new resource resolves; fallback only on first load |