Boundaries and Suspense
A boundary is a node that intercepts something flowing through the tree — an error, a pending async child, or a server-resolved value — and decides what the DOM shows in its place. Because a boundary is itself a Node<E, R> (nodes are Effects), it composes exactly like any other element: you nest it, and its children's channels flow through it under a transformation the boundary defines.
The Boundary namespace has three kinds. This page is the conceptual map; the core reference has the full signatures.
Failure boundaries
A component's E channel accumulates up the tree. A failure boundary is where you discharge some of that E: it wraps children and, if one of them fails, renders a fallback instead of letting the failure propagate to the mount.
import { Boundary, h } from "@weftui/core";
Boundary.catchAll({ fallback: (e) => h.div({ class: "error" }, `Failed: ${e.message}`) }, [
RiskyWidget(),
]);There are six failure-catch variants, mirroring Effect's own error operators so the mental model transfers directly:
| Variant | Catches |
|---|---|
catchAll |
every failure in E |
catchAllCause |
the full Cause (defects included) |
catchTag / catchTags |
one / several tagged errors by _tag |
catchSome / catchIf |
a selected subset, by Option / predicate |
The channel algebra is the whole reason they exist: catchTag("Foo", …) removes Foo from the children's E and adds whatever the fallback needs — so the type of the boundary node reflects exactly which failures are still live and which were handled. An unhandled failure re-raises to the nearest enclosing boundary; if none catches it, the mount fails. Boundaries nest, so an inner catchTag can handle a specific case while an outer catchAll sweeps the rest.
Suspense boundaries
Boundary.suspend wraps async children and shows a fallback until all of them have emitted their first value, then swaps atomically — either everything is visible or nothing is. This prevents partial flicker when sibling async regions resolve at different times.
import { Boundary, h } from "@weftui/core";
Boundary.suspend({ fallback: h.div({ class: "spinner" }, "Loading…") }, [
AsyncCard({ id: 1 }),
AsyncCard({ id: 2 }),
]);A suspense boundary is transparent to the type channels: its node is Node<ChildrenE, ChildrenR> — the children's E/R pass straight through, exactly as they would for a plain h.* parent. It changes timing (when the children become visible), not types.
On the server, renderToStreamHydratable emits the fallback inline and appends patch scripts as children resolve; on the client, hydrate sees through the boundary and adopts the already-resolved DOM directly.
Note. There is no
Suspenseexport — the API isBoundary.suspend(props, children). Reach for it for async that loads on the client; for data that must resolve on the server and hydrate without a second request, useBoundary.rpc(below).
The rpc boundary
Boundary.rpc is the server-data boundary: it resolves one Rpc on the server, serializes the result into the HTML, replays it on the client during hydrate (no second request, no flash), and then keeps the region live for refetch. Conceptually it is the same idea as the other boundaries — a node that decides what renders in a subtree — but the thing it intercepts is a round-trip to a server handler, and instead of a children array it takes a render function that receives a reactive Resource.
import { Boundary, h } from "@weftui/core";
import { Stream } from "effect";
Boundary.rpc(
GetStock,
() => ({ id: productId }),
(resource) => h.span([Stream.map(resource.value.changes, (s) => String(s.units))]),
{ fallback: h.p("loading…") },
);Unlike the failure and suspense boundaries, Boundary.rpc is not self-contained: it resolves through the ambient AppRpcClientTag seam that @weftui/router provides on both sides. Its channel behavior is also distinct — the rpc's typed error schema joins the node's E (replayable through an enclosing failure boundary), while render's R passes through untouched. The full model — the contract/handler split, the four lifecycles, typed-failure replay — is a how-to, not repeated here: Load Data with RPC.
One tree, three interceptors
The unifying idea: failure, async pending state, and server data are not three separate subsystems bolted onto the renderer. They are three boundary nodes in the one tree, each intercepting a different thing flowing through it, each with channel behavior you can read off its type. That is why they nest freely — a Boundary.catchTag can wrap a Boundary.rpc to catch its typed failure, and a Boundary.suspend can wrap async siblings that themselves contain rpc boundaries.
See also
- The Rendering Model — why a boundary is just a node in a static tree
BoundaryAPI reference — every variant's signature and channel algebra- Load Data with RPC — the full
Boundary.rpcwalkthrough and its four lifecycles - Render on the Server — how suspense and rpc boundaries stream and hydrate