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
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.
Subgraph eviction
If something depends on the current user, it should not outlive them. Frond represents that as graph ownership: auth changes, user-scoped nodes evict, Effect interrupts work and closes scopes.
Without Frond
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.
}With Frond
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");
}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" })
),
}),
});
}State management
State is the surface. Lifecycle is the product. Nodes are MobX-observable domain objects. The useful part is not another setter or cache key — it's the runtime contract around that state.
State tools answer
Still outside the model
Frond answers
End-to-end type safety
Every dep(ProfileNode) carries the node's type. Every driver return
becomes node.result. If your backend is typed — tRPC, gRPC, OpenAPI —
the types propagate through the entire runtime graph to the React consumer.
Consumers infer the result shape.
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.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
}
}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} />;
}dep(ProfileNode) knows the result type.
Dependents inherit it. React reads it. If the driver changes shape, the compiler
catches every consumer.
Errors are runtime
catch (e: unknown) is not error handling — it's a guess.
In Frond, failure flows through the same graph as values. Every error has a kind,
a tag, and a cause chain. Dependents know what broke. The runtime ships the chain
to your tracker.
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.
// 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, ...// 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],
});kind and retryable, contexts carry the full chain.
Wire it once.
Effect, under the hood
Cancellation, scoped resources, structured concurrency, retries — these lifecycle
guarantees run on Effect underneath. You never see it. You never import it.
Frond.Driver.Async compiles into the same runtime.
Cancellation
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
Sockets, subscriptions, intervals — register them with disposers.add(...).
Release runs them in reverse order, on the runtime path.
Composable failure
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
Swap Frond.Driver.Async for Frond.Driver.Effect
and you get retry, bounded concurrency, timeouts, and declarative failure
classification — composed, not hand-rolled.
Schedule.exponential vs. your own backoff loop.Effect.all({ concurrency }) vs. your own Promise gate.while: (e) => … vs. nested if/else in catch.// 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.gen. The escape hatch is there if you want it.
Complete story
Frond is not a router, renderer, or component framework. It is the graph behind them: services, resources, facades, identity, readiness, lifecycle, cleanup, and correctness.
Probably not
Probably yes