This is not a library. This is a dissection of how React 19's use() hook and Suspense work inside Fiber, exposed by building the simplest possible cache that uses them correctly.
Every line of code exists because React's internals demand it. There is no abstraction, no indirection, no safety net. Just the raw contract between your code and the reconciler.
Decision: The cache is a Map at the top of the module. Never passed as props. Never in context. Never in a ref.
const registry = new Map<string, CacheEntry<any>>();
Why this matters to Fiber:
When a component calls use() and the thenable is pending, React does NOT return control to your code. It throws the thenable. The throw unwinds through beginWork → caught by throwException → React walks up the fiber tree to a SuspenseComponent → shows fallback. Your component is gone from the call stack.
Later, when the promise resolves, React re-executes the component FROM SCRATCH. The entire function body runs again. Every local variable is recreated.
If the cache were a local variable, a useRef, or a useMemo, it would be recreated on retry. The new code would call getThenable(), find no entry, call the fetcher a second time, create a SECOND Promise, and throw a second time. But React's ping is attached to the FIRST Promise — the one that already resolved. The second Promise never pings React. The Suspense boundary stays on the fallback forever.
This is not a bug in React. It is the correct behavior of a design where the reconciler does not assume your component has side effects. The module-level singleton is the ONLY way to guarantee that the same Map entry is read on every render attempt, because the module scope survives retries.
Decision: Every cache entry stores { status, promise, value, reason }. The status, value, and reason are ALSO set directly on the Promise object via (promise as any).status = 'pending'.
Why this matters to use():
React 19's use() reads properties from the thenable object ITSELF:
function use(thenable) {
if (thenable.status === 'fulfilled') return thenable.value;
if (thenable.status === 'rejected') throw thenable.reason;
if (thenable.status !== 'pending') {
thenable.status = 'pending';
thenable.then(
v => { thenable.status = 'fulfilled'; thenable.value = v; },
e => { thenable.status = 'rejected'; thenable.reason = e; }
);
}
throw thenable;
}
It does NOT read from your cache entry. It reads thenable.status, thenable.value, thenable.reason — properties ON the thenable object.
If these properties only exist in your cache Map entry, use() sees undefined for .status, enters the "first encounter" branch, attaches its OWN .then() handlers, sets status = 'pending', and throws. On retry, it does the same thing again — because your getThenable only sets the properties in the Map, not on the thenable. The component suspends forever.
The fix: set the properties on both the entry AND the promise object:
(promise as any).status = 'pending';
And in the .then() handlers:
(promise as any).status = 'fulfilled';
(promise as any).value = value;
Now when use() checks thenable.status, it sees 'fulfilled' on the promise itself. It returns thenable.value. No second throw.
Decision: Two functions that share the same registry but serve different consumers.
| Function | Behavior | Consumer |
|---|---|---|
getResource(key, fetcher) |
Returns T or throws the promise/reason |
Direct use with <Suspense> (React 18 pattern) |
getThenable(key, fetcher) |
Returns Promise<T> — never throws |
use() hook (React 19 pattern) |
Why two paths exist:
use() is NOT a hook. It is a function that can be called conditionally. But it requires a thenable as input, not a value. getResource throws the promise before use() ever sees it. So useSledge cannot use getResource.
The internal flow of getThenable:
getThenable(key, fetcher):
cached = registry.get(key)
if cached exists → return cached.promise (has .status/.value/.reason attached)
promise = fetcher()
entry = { status: 'pending', promise, value: undefined, reason: undefined }
(promise as any).status = 'pending'
registry.set(key, entry)
promise.then(onFulfilled, onRejected) // updates entry + promise properties
return promise
// NO throw — the caller (useSledge) passes this to use()
getResource delegates to getThenable for the cold path:
getResource(key, fetcher):
cached = registry.get(key)
if cached exists:
if fulfilled → return cached.value
if rejected → throw cached.reason
if pending → throw cached.promise // Suspense (React 18 style)
// Cold path: reuse getThenable, then throw
const thenable = getThenable(key, fetcher)
throw thenable
This avoids code duplication. The entry creation logic lives in one place (getThenable). getResource uses it when the entry doesn't exist.
Decision: Every entry has exactly three status values — 'pending', 'fulfilled', 'rejected'. No other values. No enums. No Symbols.
Why only these three:
React's use() checks:
thenable.status === 'fulfilled' → return value
thenable.status === 'rejected' → throw reason
thenable.status !== 'pending' → first encounter: attach handlers
→ fall through to throw
If your status is 'loading', use() evaluates thenable.status !== 'pending' as true (because 'loading' !== 'pending'). It enters the "first encounter" branch, OVERWRITES your status to 'pending', attaches its OWN .then() handlers, and throws. On retry, your getThenable returns the promise — but use() already overwrote the status. If the promise hasn't resolved yet, use() sees 'pending' and throws again. The cycle repeats indefinitely.
The fix: use 'pending' as the string. Not 'loading'. Not 'fetching'. The exact string 'pending' — because that is the only value that tells use() "I already know about this thenable, do not touch my handlers."
Decision: There are no comments in the code. Every assumption is encoded in the type system and the structure.
Why this matters for learning:
The code is the source of truth. The comments would describe what the code should do. If they disagree, the code wins. By removing comments, we force the code to be self-explanatory — and if it isn't, the design is wrong.
The architectural knowledge lives in ARCH.md. The code lives in .ts files. They are separate because they serve different readers: the compiler reads the code, humans read the architecture doc. Mixing them creates maintenance drag — when the code changes, the comments rot.
Decision: useSledgeLive calls BOTH use() and useSyncExternalStore. It uses use() for initial load and useSyncExternalStore for live updates.
Why use() alone is not enough:
A Promise resolves once. After that, thenable.status is 'fulfilled' forever. use() reads it, sees 'fulfilled', returns the value, and does nothing else. If updateResource() overwrites the entry's value, the promise's .status is still 'fulfilled', but use() has no mechanism to re-read. It already returned.
Why useSyncExternalStore alone is not enough:
useSyncExternalStore needs a getSnapshot function that returns the current value. If the entry doesn't exist yet, getSnapshot returns undefined. But the component needs to SUSPEND, not render with undefined. You could throw a promise from getSnapshot, but getSnapshot is called during React's render phase, and throwing from it bypasses React's Suspense handling — it goes straight to ErrorBoundary.
The fix: Use use() to suspend (it throws the thenable correctly into the Fiber pipeline), then use useSyncExternalStore to subscribe after the initial render commits.
function useSledgeLive(key, fetcher) {
const thenable = getThenable(key, fetcher);
const snapshot = useSyncExternalStore(
cb => subscribeToKey(key, cb),
() => readCache(key),
() => undefined
);
if (snapshot === undefined) {
return use(thenable); // Suspense path
}
return snapshot; // Live read path
}
The branching works because use() CAN be called conditionally — it is not a hook.
Decision: Listeners are stored in a separate Map<string, Set<() => void>>, not on the cache entry itself.
Why separate from the entry:
The entry stores data. The listeners store change notifications. They have different lifetimes and different access patterns.
- The entry is read on EVERY render (hot path). Adding a listener set to every entry would allocate memory for non-streaming keys.
- Listeners are only needed for
useSledgeLive. ForuseSledge, no subscription is required. - When
clearCache(key)is called, we delete the entry AND the listeners. If they were merged, deleting the entry would orphan the listeners (or vice versa).
The decoupling also matches React's design: useSyncExternalStore receives a subscribe function and a getSnapshot function. They are separate contracts. subscribe registers a callback; getSnapshot reads data. Merging them would break this contract.
Decision: entry.reason is the raw value from Promise.reject(reason). No wrapping. No normalization.
Why this matters:
When use() sees thenable.status === 'rejected', it executes throw thenable.reason. This throw goes through throwException, which checks typeof thrownValue.then === 'function'. Since a bare Error object does NOT have a .then method, the throw routes to the ErrorBoundary path, not Suspense.
If you wrap the rejection reason in a new Error, you:
- Lose the original stack trace
- Make it impossible for ErrorBoundaries to distinguish error types (network vs auth vs validation)
- Add an allocation to every rejection
The only normalization needed: if the promise rejects with undefined or null, React's throw handler crashes because typeof undefined.then throws a TypeError during error recovery. The fix is in the promise rejection handler:
(reason) => {
const normalized = reason != null ? reason : new Error(String(reason));
entry.status = 'rejected';
entry.reason = normalized;
(promise as any).status = 'rejected';
(promise as any).reason = normalized;
}
But for user errors (explicit reject(new Error(...))), no normalization is needed.
Decision: Prefetching uses the same getThenable function as the hooks. There is no prefetch(key, fetcher) function.
Why a single function:
If prefetching created a separate Promise (e.g., const p = fetch(url)), and the hook created a different promise via getThenable, there would be two Promises for the same key. The hook's promise would be pending. The prefetch promise would resolve and update... nothing. The hook's promise is still pending. The component stays suspended.
getThenable is the single point of entry creation. Whether you call it from an event handler (prefetch) or from useSledge (render), it creates the same entry with the same Promise. One Promise. One ping. One retry.
// Event handler — prefetch
button.onclick = () => getThenable('user:42', fetchUser);
// Creates entry, starts fetch, returns promise (we ignore it)
// Later, during render — consume
function Profile() {
const user = useSledge('user:42', fetchUser);
// Same entry exists, possibly already fulfilled → no Suspense
// If still pending → use() suspends
}
Decision: fetcher() is called exactly once per key. The return value is stored. The fetcher reference is not held after that.
Why this matters:
The fetcher might close over component-scoped variables. If the cache held a reference to the fetcher, those closures would prevent garbage collection of the component's fiber and hooks tree. For thousands of keys, this leaks memory.
Also: the fetcher is called during the COLD path of getThenable, which is outside the render phase (the render phase threw immediately after). But the closure variables might reference stale state. By not storing the fetcher, we ensure:
- No memory leak from closed-over state
- The cache does not depend on component lifecycle
- The assertion "one fetch per key" is enforced by the code (registry check before calling fetcher)
Decision: updateResource always writes entry.status = 'fulfilled', even if it's already 'fulfilled'.
Why the write is not optional:
In JavaScript, object property writes act as memory ordering barriers in V8's optimizing compiler (specifically, they force a store-store barrier on ARM CPUs). When updateResource writes entry.value = newData and then entry.status = 'fulfilled', the compiler guarantees that the value write is globally visible before the status write.
If we skipped the status write (because it was already 'fulfilled'), a concurrent render on another thread (or a CPU-reordered execution) could read entry.status === 'fulfilled' (the old value, which is still 'fulfilled') but read a partially-written or stale entry.value. This is a tearing read.
The write to status is a commit signal: "all other fields are now consistent." Even though the value doesn't change, the write ensures ordering.
Decision: 12 tests that verify the cache contract without React. No jsdom. No React renderer.
Why no React in tests:
The cache registry is a plain TypeScript module. It does not import React. getResource, getThenable, updateResource, readCache, clearCache are all synchronous (the .then() handlers are async but the functions themselves are sync). They can be tested with vitest in Node.js.
What the tests verify:
| Test | What it catches |
|---|---|
getResource throws the raw promise |
Ensures the thrown value is a Promise (duck-typing check via .then) |
getResource returns value after resolution |
Verifies the pending → fulfilled state transition works |
getResource throws reason after rejection |
Verifies pending → rejected throws the reason |
calls fetcher exactly once per key |
Verifies the singleton contract — retries hit cache, not fetcher |
getThenable returns without throwing |
Verifies the non-throwing path |
getThenable returns same thenable on repeat |
Verifies referential identity — the same Promise object is returned |
getThenable attaches status/fulfilled on resolution |
Verifies the Thenable contract properties are set on the Promise |
updateResource sets value for subsequent reads |
Verifies push-based cache population |
updateResource overwrites existing value |
Verifies in-place mutation |
updateResource notifies subscribers |
Verifies notifyKey fires listeners |
clearCache removes a single key |
Verifies targeted invalidation |
clearCache removes all keys |
Verifies full invalidation |
These tests exercise the entire contract. If they pass, the cache correctly implements the Thenable contract. If they don't, no React component will work correctly either.
sledge/
├── ARCH.md # This file — full architecture deep-dive
├── src/
│ ├── lib/
│ │ ├── sledge-cache.ts # Core: registry, getThenable, getResource,
│ │ │ # updateResource, subscribeToKey, readCache, clearCache
│ │ ├── sledge-hooks.ts # useSledge (use) + useSledgeLive (use + uSES)
│ │ └── sledge-stream.ts # Binary stream generator for the live example
│ ├── components/
│ │ ├── ErrorBoundary.tsx # Class component catching rejected thenable throws
│ │ ├── SuspenseFallback.tsx # Loading indicator
│ │ ├── UserProfile.tsx # useSledge with simulated fetch
│ │ ├── StreamDisplay.tsx # useSledgeLive with useSyncExternalStore for chunks
│ │ └── StreamControls.tsx # Start/Stop/Reset for the stream simulator
│ ├── examples/
│ │ ├── BasicSuspense.tsx # Switch users, see Suspense cycle in action
│ │ ├── BinaryStream.tsx # Real-time binary chunks, live subscription
│ │ └── PrefetchExample.tsx # Pre-populate cache, zero fallback flash
│ ├── App.tsx # Tab-based navigation
│ └── main.tsx # createRoot + StrictMode
└── tests/
└── sledge-cache.test.ts # 12 tests, no React, pure Thenable contract
npm install
npm run dev # http://localhost:5173 — 3 example tabs
npm test # 12 tests, all must pass
npm run build # TypeScript check + production bundleDemonstrates: pending → throw → fallback → resolve → retry → commit.
Switch between User 1/2/3. Each click clears the cache for that user and re-creates the entry. Watch the 2-second fallback, then the data appears. The UI does NOT unmount between switches — the same components re-render with new data because the Suspense boundary is above them.
Demonstrates: use() for initial load, useSyncExternalStore for live updates.
Click "Start stream". The stream metadata fetches (1.5s via Suspense). When the StreamDisplay commits, useSyncExternalStore subscribes to the chunk key. The stream pushes Uint8Array data every 300ms via updateResource. Each push calls notifyKey, which fires the store listener, which triggers a synchronous re-render. The chunk info updates in real-time.
Demonstrates: cache-first architecture.
Click "Prefetch" — getThenable creates the entry outside React's render phase. Click the user area — useSledge reads the pre-resolved value. If the prefetch completed: use() sees status: 'fulfilled' and returns immediately — no Suspense. If still pending: use() throws for Suspense — standard fallback.
Component: use(thenable)
│
├─ thenable.status === 'fulfilled'
│ └─ return value (done)
│
├─ thenable.status === 'rejected'
│ └─ throw reason → ErrorBoundary (done)
│
└─ thenable.status === 'pending'
└─ throw thenable
│
▼
beginWork catches throw
│
▼
throwException(root, returnFiber, sourceFiber, thrownValue, lanes)
│
├─ typeof thrownValue.then === 'function'? YES → Suspense path
│
├─ Walk fiber.return chain → find SuspenseComponent
│
├─ attachPingListener: thenable.then(ping, ping)
│
├─ boundary.flags |= ShouldCapture
│ boundary.memoizedState.timedOut = true
│ source.flags |= DidCapture
│
▼
Render phase completes (Suspense boundary cuts the tree)
│
▼
Commit phase:
├─ ShouldCapture → DidCapture
├─ OffscreenComponent → mode = 'hidden'
├─ Insert fallback DOM
└─ promise.then(ping, ping) — safety re-attach
│
▼
Promise resolves:
├─ entry.status = 'fulfilled'
├─ (promise as any).status = 'fulfilled'
├─ notifyKey(key) — live subscribers fire
└─ ping() fires
├─ markRootUpdated(root, pingedLanes)
├─ ensureRootIsScheduled(root)
└─ removeFromSuspendedLanes(root, pingedLanes)
│
▼
Retry render:
├─ getThenable → same promise, now fulfilled
├─ use(thenable) → status === 'fulfilled' → return value
└─ Render completes normally
│
▼
Commit phase:
├─ DidCapture cleared
├─ timedOut = false
├─ Offscreen → mode = 'visible'
├─ Remove fallback DOM
├─ Reattach primary content DOM
└─ Effects flush (useEffect callbacks run)
ARCH.md(this file) — understand why every decision was madesrc/lib/sledge-cache.ts— the core cache, read it top to bottomsrc/lib/sledge-hooks.ts— the hooks, 33 lines totalsrc/components/UserProfile.tsx— simplest consumersrc/examples/BasicSuspense.tsx— puts it all togethersrc/components/StreamDisplay.tsx— uses both use() and useSyncExternalStoretests/sledge-cache.test.ts— proves the contract without React