p.enthalabs

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.

The runtime and its graph

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.

Drivers and Effect mode

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.

Author your first node