Shells
Error boundaries for values: providers that claim failure classes, pause or escalate, and subtract what they own from component unions.
Remember the 401 interceptor. Now recall that React already solved this exact shape once, for a different kind of failure. Render errors used to be every component’s private problem; error boundaries made them positional: throw anywhere below, and the nearest boundary that claims it takes over. Three properties made that design stick:
- Tree-positional — ownership follows the UI, not the call site.
- It catches errors from components that never heard of it — a boundary that only caught opted-in throws would be useless.
- Unclaimed errors fail loudly rather than vanish.
A shell is the same contract, transplanted from thrown render errors to failure values. A shell is a provider that claims a set of error tags. Any operation rendered beneath it — no matter which hook issued it — that fails with a claimed tag is routed to the shell instead of surfacing as component state. The 401 interceptor becomes a typed declaration with a position in the tree, and the tags it owns disappear from the unions below it.
Three tiers of failure, three built-in owners
The tiers are nothing more than which definition map you hand to which shell — there is no classification field:
| What failed | Example tags | Reaction | The map |
|---|---|---|---|
| The domain said no | doc/not-found, auth/unauthorized |
the component branches, or an auth shell reacts | your defineErrors maps |
| The world flaked | client/offline, client/timeout, client/network-failure |
pause, banner, resume | transportErrors |
| The contract broke | client/protocol-violation, client/decode-failure, server/internal |
escalate to the error boundary | defectErrors |
| A deploy left this client behind | client/stale |
reload — the reload is the fix | staleErrors |
The framework contributes every non-domain row, so the framework ships their owners pre-assembled — assembling them by hand was the same ten lines in every app:
import { boundaryShells } from "result-rpc/react"
export const { TransportShell, DefectShell, StaleShell, BoundaryProvider, useConnectivity } =
boundaryShells()
// TransportShell claims transportErrors, pauses; useHeld() feeds the banner
// DefectShell claims defectErrors, escalates to the React error boundary
// StaleShell claims staleErrors; default reaction reloads the page
BoundaryProvider is also the browser bridge. It closes the reconnect arc
automatically — claim, hold, the browser fires online (or the window
regains focus while failures are held), resume — and the runtime feeds the
same connectivity source into the query engine’s online manager. Opt out
with boundaryShells({ autoResume: false }).
Accurate connectivity is also the anti-thrash lever. While the browser is
offline, reads pause — fetches and retries wait as fetch: "paused"
instead of failing instantly and burning the retry budget, and the engine
continues them on reconnect (pinned by test: a query mounted while offline
makes zero wire calls, then exactly one on reconnect). Writes stay loud — a mutation attempted
offline fails once with client/offline for its owner to decide; the
framework never queues a side effect for silent later delivery. Offline
failures are never retried on a timer while the browser still reports
offline (recovery is the reconnect, not the clock), and focus-triggered
resumes are cooled down so alt-tabbing at a downed server never becomes a
retry storm.
The table-stakes offline banner is useConnectivity() — an honest
two-source signal, exhaustively switchable like everything else here:
function ConnectionBanner() {
const net = useConnectivity()
switch (net.status) {
case "online": return null
case "offline": return <Banner>You're offline — paused work resumes automatically.</Banner>
case "degraded": return <Banner action={net.resume}>Connection trouble — retrying…</Banner>
}
}
"offline" is the browser’s own claim (navigator.onLine + events) — it
lights up before any request fails. "degraded" is the proof side: the
browser claims online, but the transport shell is holding real failures
(captive portals, flaky proxies). The two sources are kept distinct because
adjacency isn’t identity — browser connectivity is a cause-side hint,
transport errors are outcomes. The hint never mints or suppresses an error
tag and never touches the cache; it only times resumes and informs the
banner. net.held, net.latest, and net.resume are there for custom UX.
You only ever write shells for what the app itself owns:
import { defineShell } from "result-rpc/react"
import { authErrors } from "../shared/errors"
export const AuthShell = defineShell({
name: "auth",
from: StaleShell, // hang off the innermost built-in
claims: authErrors,
onError: (_error, { signOut }) => signOut(),
provide: (props: { session: Session; signOut: () => void }) => ({
user: props.session.user,
signOut: props.signOut,
}),
})
Mount them as an onion:
<ResultRpcProvider runtime={runtime}>
<BoundaryProvider> {/* transport pauses · defects escalate · stale reloads */}
<ErrorBoundary fallback={<AppBroken />}>
<AuthShell.Provider session={session} signOut={signOut}>
<Routes /> {/* components below see ONLY their domain errors */}
</AuthShell.Provider>
</ErrorBoundary>
</BoundaryProvider>
</ResultRpcProvider>
Inside Routes, an operation resolving ten possible tags presents one:
export function DocPage({ id }: { id: string }) {
const { user } = AuthShell.use() // User, not User | null
const doc = AuthShell.useQuery(client.doc.byId, { id })
switch (doc.state) {
case "pending": return <DocSkeleton />
case "success": return <DocView doc={doc.value} viewer={user} />
case "failure":
// DocNotFound — and adding a case for anything else is a type error
return <DocMissing docId={doc.error.data.docId} />
}
}
And when a query’s domain union is empty — a list that declares no domain errors, rendered under a complete onion — the failure branch becomes a compile-time certificate that the owners are mounted:
switch (issues.state) {
case "pending": return <p>Loading issues…</p>
case "success": return <IssueRows issues={issues.value} />
case "failure":
// transport, defect, stale, and auth are all claimed above; nothing
// domain remains. This line compiles ONLY while that is true — remove
// a provider from the tree and the build fails, right here.
return issues.error satisfies never
}
No test can make that claim. The type checker makes it on every build.
How claiming actually works
Claiming is per observer and tree-positional. Each hook, at its render
position, checks whether an enclosing shell claims the failure’s tag. The
cache is never rewritten: the entry still holds the real Err, refetch
bookkeeping continues underneath, and an observer of the same cache entry
rendered outside the shell still sees state: "failure". A shell changes
how a failure presents where it presents — nothing else. The innermost shell
claiming a tag owns it.
The type story has two halves:
- Shell hooks subtract.
AuthShell.useQueryremoves the chain’s claimed tags from the union — and eagerly asserts, at mount, that every shell in the chain is actually mounted above it. The subtraction is only honest if the owners exist, so a missing provider throws on first render, the same contract as any context hook without its provider. You find out on the happy path in development, not on the error path in production. - Plain hooks over-approximate.
useResultQuerykeeps the full union. Under a mounted shell, the claimed tags in that type are unreachable — the shell routes them — exactly the way atry/catchinside an error boundary lists exceptions the boundary would have caught anyway. Unreachable, not untrue; and outside any shell, the same type is exact.
Property 2 of error boundaries is why interception cannot be opt-in per hook:
if a plain hook under AuthShell could surface auth/session-expired as
component state, the shell’s guarantee — and every narrowed union derived from
it — would be a lie. Ownership is positional or it is nothing.
To genuinely own a claimed tag yourself, render outside the shell that owns
it. The login page lives outside AuthShell and handles
auth/session-expired as an ordinary failure, because there is no session to
guarantee there.
The chain is a value, not an inference
from: makes the accumulated claim set a property of the shell value.
AuthShell is typed with its own claims plus everything DefectShell and
AppShell claim, and nobody writes that union by hand. Narrowing never
depends on TypeScript inferring where a component sits in the tree — you
cannot reach AuthShell.useQuery without importing the shell that declares
the chain, and the chain proves itself mounted at runtime.
Two invariants are enforced at definition time:
- a tag may be claimed once per chain — overlapping shells are a
TypeErrorat startup, not a precedence puzzle later; - claims only accumulate inward, so an inner shell can never un-handle something an outer shell took responsibility for.
What a claimed error does to the operation
A subtracted error never produces state: "failure" — that would be a lie
about a union it is no longer in.
With effect: "pause" (the default):
-
Query — returns to a non-terminal state with
fetch: "paused". If a cached success exists it keeps rendering asstate: "success", stale, so a session blip does not blank the screen. If not,state: "pending". -
Mutation — state returns to
"idle"and the pendingmutatepromise rejects with aclaimedcontrol signal: the caller’s continuation was written against the narrowed union, so an outcome owned above it must not run it. The signal is the same family as cancellation — control flow, never part of a recoverable union — but deliberately distinguishable, because “you cancelled” and “a shell owns this outcome” are different events.isClaimed(reason)identifies it and carries the claimed tag and the owning shell’s name (never the error value), so a form can render “you were signed out” instead of silently resetting:import { isCancelled, isClaimed } from "result-rpc/client" try { await rename.mutate({ id, title }) } catch (reason) { if (isClaimed(reason)) reason.data // { tag: "auth/session-expired", owner: "auth" } else if (isCancelled(reason)) {} // user cancelled; nothing happened for sure else throw reason } -
Subscription —
connectionbecomes"paused"andresultstaysundefined.
With effect: "escalate", the tagged value is thrown to the nearest React
error boundary — as the structural TaggedError, not wrapped in an Error,
so the fallback can still matchError on it. Escalate is the bridge back to
the machinery React already has.
onError fires once per newly claimed error per observer. One logical event —
a revoked session — arrives on every in-flight operation at once, so handlers
must be idempotent (a redirect, a signOut(), a toast keyed by tag).
The pause arc ends in resume
Held is not stuck. Every held operation carries a retry handle, and the shell exposes the whole set:
const { latest, affected, resume } = AuthShell.useHeld()
// after re-authenticating:
resume() // every held query refetches; held subscriptions reconnect
Held mutations reset to idle — their failure already reached the caller as the claimed rejection, and replaying a side effect is never the shell’s call. Resetting ends the pause arc, so holdings (and the connection banner) drain on resume.
Layer shells (below) close the loop automatically: when the layer’s context
procedure re-establishes its value (sign back in, invalidate client.auth.me),
every operation the shell was holding resumes without a line of app code.
Mid-session revocation therefore plays out as: refetch fails → shell holds it
→ the stale value keeps rendering → re-auth → held work refetches fresh. The
screen never blanks and no component ever branched on it.
Unmounting a holding shell releases its holdings cleanly — observers release
on their own unmount, onError does not re-fire, nothing leaks. A fresh mount
is a fresh world: a cached failure encountered again is claimed again.
Ambient failures are aggregate, not per-operation
Twelve paused queries are not twelve offline states. The shell holds them together:
function OfflineBanner() {
const { latest, affected } = AppShell.useHeld()
if (!latest) return null
return <Banner tag={latest._tag} count={affected} />
}
That is the structural reason the per-operation error channel was the wrong home for connectivity: no single operation owns it.
The server declares, the client discharges
Middleware adds an error to the union and produces context. A shell removes the error and produces context. They are inverses over the same declaration:
// shared/errors.ts
export const authErrors = { Unauthorized, SessionExpired }
// server
const authenticated = app.middleware<{ user: User }>().errors(authErrors).use(/* ... */)
// client
const AuthShell = defineShell({ name: "auth", claims: authErrors, /* ... */ })
Add an error to authErrors and every derived shell absorbs it; no component
union changes and nothing breaks. Remove one and the components that branched
on it stop compiling. The shared map is a value in the shared contract
package, so no server middleware code reaches the browser bundle.