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. The shape is universal. 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.
Current app shape
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.
}Frond shape
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.
Zero annotations.
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 — every correctness
guarantee on this page is 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, every time. No leaks, no manual lifecycle.
Composable failure
A driver throws. The runtime catches, classifies, attaches the cause chain, and notifies every dependent. The same machinery that gave you the structured error 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