Frond — The frontend runtime graph for React apps
React is not your runtime.Frond is.
Your app already has a runtime. It's scattered across providers, effects, and cleanup scripts. Frond makes it a graph. Effect runs it. React stays a renderer.
Every growing frontend app arrives at the same problems: how services depend on each other, and what to clean up when the current user changes. **Most growing frontend apps hit the same shape. The implementation is a checklist you maintain by hand.**
Without Frond
today / sign-out checklist
``` async function signOut() { await session.end();
// ↓ manually list every user-scoped thing. localStorage.removeItem("token"); queryClient.clear(); // cached queries abortInFlightRequests(); // open fetches presenceChannel.leave(); // realtime presence socket.disconnect(); // realtime transport billingStore.reset(); // domain store navigate("/login");
// added a new user-scoped service? // remember to add a line here too. } ```
Manual memory Every new user-scoped service adds another line to remember. Miss one and the old user can leak through stores, sockets, analytics identity, stale updates, or running requests.
With Frond
frond / auth action
``` type SessionSpec = Frond.NodeSpec<{ readonly args: Frond.Args.None; readonly key: Frond.Key.Singleton; readonly result: Session; }>;
export class SessionNode extends Frond.NodeBase<SessionSpec> { static readonly spec = Frond.serviceSpec<SessionSpec>({ tag: Frond.tag("app/session"), key: () => Frond.Key.singleton(), driver: Frond.Driver.Async<SessionSpec>({ acquire: Frond.Driver.Acquire(({ signal }) => restoreSession(signal) ), }), }); }
// one call — every dependent node is // evicted, interrupted, and released. function useSignOut() { const controls = FrondReact.useNodeControls(SessionNode, {}); return () => controls.evict("selfAndDependents", "sign-out"); } ```
frond / user-scoped resource
``` type PresenceSpec = Frond.NodeSpec<{ readonly args: Frond.Args.None; readonly key: Frond.Key.Singleton; readonly deps: { readonly socket: Frond.Dep<typeof SocketNode>; readonly session: Frond.Dep<typeof SessionNode>; }; readonly result: PresenceChannel; }>;
export class PresenceNode extends Frond.NodeBase<PresenceSpec> { static readonly spec = Frond.resourceSpec<PresenceSpec>({ tag: Frond.tag("app/presence"), key: () => Frond.Key.singleton(), dependencies: Frond.dependencies(() => ({ socket: Frond.dep(SocketNode, Frond.Args.none), session: Frond.dep(SessionNode, Frond.Args.none), })), driver: Frond.Driver.Async<PresenceSpec>({ // join the user's presence channel on acquire — // socket heartbeats on its own cadence. acquire: Frond.Driver.Acquire(({ deps }) => deps.socket.result.join("presence", { userId: deps.session.result.userId, heartbeat: 5_000, }) ), // release pairs with acquire — // signOut() never has to know about presence. release: Frond.Driver.Release(({ node }) => node.result.leave({ reason: "sign-out" }) ), }), }); } ```
Runtime boundary Cleanup belongs to the node that acquired the resource. Eviction runs release, cancels in-flight work, clears readiness, and rejects stale commits for the evicted graph record.
Read about eviction and release
State tools answer
Where does the value live?
Redux / Zustand value, mutation, selector
React Query server cache, invalidation, retry
MobX observable domain state
Context value wiring through React
Still outside the model
Who owns the lifecycle?
- What must be ready before this value can load?
- Which keyed identity is this state attached to?
- What cancels in-flight work when dependencies change?
- Who rejects stale commits after eviction?
- Where do release, telemetry, and reset live?
Frond answers
When is state allowed to exist?
- identity
- observable state
- dependencies
- readiness
- actions
- scope
- release
- eviction
Visible state The cache result, observable fields, and computed getters are visible. Frond keeps those ergonomics, then attaches them to graph identity, readiness, cancellation, release, and eviction.
Runtime lifecycle React reads a node. MobX makes it observable. Effect runs the work. Frond owns when the node is alive, ready, stale, released, or dead.
1. backend schema 2. driver return 3. node.result 4. deps.x.result 5. useNode()
define / typed driver
``` type ProfileSpec = Frond.NodeSpec<{ readonly args: Frond.Args.None; readonly key: Frond.Key.Singleton; readonly deps: { readonly auth: Frond.Dep<typeof AuthNode>; readonly api: Frond.Dep<typeof ApiNode>; }; readonly result: Profile; }>;
export class ProfileNode extends Frond.NodeBase<ProfileSpec> { static readonly spec = Frond.resourceSpec<ProfileSpec>({ tag: Frond.tag("app/profile"), key: () => Frond.Key.singleton(), dependencies: Frond.dependencies(() => ({ auth: Frond.dep(AuthNode, Frond.Args.none), api: Frond.dep(ApiNode, Frond.Args.none), })), driver: Frond.Driver.Async<ProfileSpec>({ acquire: Frond.Driver.Acquire(async (ctx) => { // ctx.deps.auth.result → AuthState // ctx.deps.api.result → ApiClient return await ctx.deps.api.result.user.profile.query({ userId: ctx.deps.auth.result.userId, signal: ctx.signal, }); }), }), }); } // Profile inferred from driver return — no annotation. ```
depend / types propagate
``` type BillingSpec = Frond.NodeSpec<{ readonly args: Frond.Args.None; readonly key: Frond.Key.Singleton; readonly deps: { readonly profile: Frond.Dep<typeof ProfileNode>; readonly api: Frond.Dep<typeof ApiNode>; }; readonly result: Billing; }>;
export class BillingNode extends Frond.NodeBase<BillingSpec> { static readonly spec = Frond.resourceSpec<BillingSpec>({ tag: Frond.tag("app/billing"), key: () => Frond.Key.singleton(), dependencies: Frond.dependencies(() => ({ profile: Frond.dep(ProfileNode, Frond.Args.none), api: Frond.dep(ApiNode, Frond.Args.none), })), driver: billingDriver, });
// no annotation — inferred from dep(ProfileNode). get plan() { return this.deps.profile.result.plan; // ^? Plan } } ```
consume / zero annotations
``` function BillingPage() { // runtime hands a ready BillingNode — // no isLoading, no fallback, no guards. const node = FrondReact.useNode(BillingNode, {});
// node.plan inferred as Plan // through the dep(ProfileNode) chain. return <PlanBadge plan={node.plan} />; } ```
No consumer casts, no manual dependency wiring The graph is the type system. `dep(ProfileNode)` knows the result type. Dependents inherit it. React reads it. If the driver changes shape, the compiler catches every consumer.
Spec and class — how typed nodes work
Structured
Failures carry `kind`, `tag`, `retryable`, and a cause chain. No `e: unknown`, no guessing what `null` means.
Walked
The runtime walks the chain into a serializable report — fingerprint, tags, contexts, dependency aggregates, runtime event metadata. You don't write the projection.
Wired
Drop a sink into the runtime once. Every failure routes to your tracker with graph-aware grouping. No per-component `try/catch`, no remembering to capture.
today / catch and reconstruct context
``` // scattered across every fetch, hook, boundary — // each catch builds its Sentry context by hand. async function loadProfile(userId: string) { try { return await api.getProfile(userId); } catch (e) { Sentry.captureException(e, { tags: { feature: "profile" }, // is it readiness? auth? // a flattened DependencyFailed? // we only have `e: unknown`. // no chain (lost three try/catches ago) // no retryable flag // no consistent fingerprint }); throw e; } }
// repeat for billing.ts, // feed.ts, dashboard.ts, ... ```
frond / one sink, walked chain
``` // One sink. Every failure in every node // flows to Sentry with graph-aware grouping. // (Or any tracker — the report shape is generic.)
const sentrySink = Frond.Diagnostics.createRuntimeReportSink({ name: "sentry", handleReport: ({ report }) => { Sentry.captureException(report.error, { fingerprint: [...report.fingerprint], // ["frond", kind, rootTag, nodeTag] tags: report.tags, // { "frond.kind", "frond.retryable", // "frond.root_tag", "frond.node_tag" } contexts: report.contexts, // { frond, causeChain, dependencyFailures, // runtimeEvent } extra: report.extra, }); }, });
const runtime = Frond.createRuntime({ sinks: [sentrySink], }); ```
Errors are part of the model The runtime classifies, walks the cause chain, and builds a report shaped for Sentry-style trackers — fingerprint groups by graph topology, tags carry `kind` and `retryable`, contexts carry the full chain. Wire it once.
How errors flow through the graph
Cancellation
Signals everywhere
Every acquire and refresh receives a `signal` wired to its scope. When a node evicts, in-flight work is interrupted — fetches abort, timers clear, streams close.
Scoped resources
Cleanup runs in reverse
Sockets, subscriptions, intervals — register them with `disposers.add(...)`. Release runs them in reverse order, on the runtime path.
Composable failure
Throw, propagate, structure
A driver throws. The runtime catches, classifies, attaches the cause chain, and notifies every dependent. The runtime uses the same cause-chain reporting shown above.
Opt in
Write the orchestration you'd write anyway.
Swap `Frond.Driver.Async` for `Frond.Driver.Effect` and you get retry, bounded concurrency, timeouts, and declarative failure classification — composed, not hand-rolled.
Retry`Schedule.exponential` vs. your own backoff loop.
Concurrency`Effect.all({ concurrency })` vs. your own Promise gate.
Classification`while: (e) => …` vs. nested `if/else` in catch.
frond / effect-mode driver, retry + concurrency + classify
``` // DashboardSpec: facade, api dep, three-panel result.
export class DashboardNode extends Frond.NodeBase<DashboardSpec> { static readonly spec = Frond.facadeSpec<DashboardSpec>({ tag: Frond.tag("app/dashboard"), key: () => Frond.Key.singleton(), dependencies: Frond.dependencies(() => ({ api: Frond.dep(ApiNode, Frond.Args.none), })), driver: Frond.Driver.Effect<DashboardSpec>({ acquire: Frond.Driver.Acquire((ctx) => Effect.gen(function* () { const fetchPanel = (panel: PanelId) => ctx.tryPromise((signal) => ctx.deps.api.result.dashboard.panel(panel, signal) ).pipe( // exponential backoff, fail fast on auth. Effect.retry({ schedule: Schedule.exponential("100 millis"), times: 3, while: (e) => e._tag !== "AuthError", }), Effect.timeout("5 seconds"), );
// three panels in parallel, two in flight at a time. const [activity, billing, feed] = yield* Effect.all( [fetchPanel("activity"), fetchPanel("billing"), fetchPanel("feed")], { concurrency: 2 } );
return { activity, billing, feed }; }) ), }), }); } ```
Effect is the engine, not the API You get cancellation, scopes, and structured failure without writing a single `Effect.gen`. The escape hatch is there if you want it.
Probably not
- Your app mostly renders independent screens.
- Data loading is local to a page.
- Logout clears one token and one cache.
- React Query explains most async state.
- You do not have long-lived frontend services.
Probably yes
- Startup has real readiness gates.
- Services depend on other services.
- User identity invalidates half the app.
- Sockets, SDKs, analytics, and transports need cleanup.
- Screens aggregate many resources.
- You need to know why something is not ready.