v0 · Developer Preview Frond is under active development. APIs may change between releases.

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.

runtime graph — click a node to inspect

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

Logout is not a cleanup script.

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

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.

Frond shape

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 dead graph.
Read about eviction and release

State management

"Another state manager."

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

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
State is the surface 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.
Lifecycle is the product 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

End-to-end type safety

And the lifecycle is typed.

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.

  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 generics, no casting, no manual 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

Errors are runtime

Errors are not an afterthought.

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.

today / catch and pray
// 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

Effect, under the hood

Effect runs the work. You don't have to.

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

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, every time. No leaks, no manual lifecycle.

Composable failure

Throw, propagate, structure

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

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 the Effect engine

Complete story

Your app has a runtime. Make it explicit.

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

  • 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

Your runtime is already there. Wire it once.

Install Frond