# Frond Documentation
> Frond — explicit frontend runtime. Readiness, dependencies, cleanup, eviction, and race control as a graph.
This file is generated mechanically from the Markdown/MDX source for the Frond documentation.
---
# Install
URL: https://frondruntime.dev/docs/start/install
Description: Install Frond, set up TypeScript, and create the runtime your app will live on.
Frond is two packages. Install them, create a runtime, and hand it to React.
## What you'll install
- **`@frondruntime/core`** — the runtime, drivers, lifecycle, errors, diagnostics. Required.
- **`@frondruntime/react`** — ``, `useNode`, `useNodes`, `useNodeState`, `Preload`. Required only if you render with React.
Install Frond with its peer runtime packages. **Effect** powers Frond execution and must be v4-compatible. **MobX** powers node observation and must be shared with app code. React apps also install `mobx-react-lite` because Frond's React examples and MobX-backed node reads use `observer`.
## Install
```sh
bun add @frondruntime/core @frondruntime/react effect mobx mobx-react-lite
```
Or with npm / pnpm / yarn — whichever your project uses.
If you're not using React (CLI tool, headless service, background worker), drop `@frondruntime/react`.
## Requirements
- **Node 20.19+** (or 22.12+) or **Bun 1+**
- **React 18+** when using `@frondruntime/react`
- **TypeScript** with `strict` on. Frond's types lean on `strictFunctionTypes` and `exactOptionalPropertyTypes` to preserve lifecycle type contracts. If you have these off, the API still works, but you'll lose some of the safety the runtime is built around.
```json
{
"compilerOptions": {
"strict": true,
"exactOptionalPropertyTypes": true,
"strictFunctionTypes": true
}
}
```
## Create a runtime
The runtime is the root of your graph. Create one per app (tests use a harness instead; see [Testing](/docs/reference/testing)).
```ts
// src/runtime.ts
```
The runtime is created. It holds no nodes until one is requested.
## Hand it to React
If you're rendering React, mount `FrondProvider` at the root and pass the runtime in.
```tsx
// src/App.tsx
return (
);
}
```
Every component below the provider can read nodes.
## Imports
Two namespaces, one each.
```ts
```
Testing helpers live on the `/testing` subpaths:
```ts
```
Don't import from internal source paths. They're not public API and will move.
---
Next: define your first node and read it.
---
# Your first node
URL: https://frondruntime.dev/docs/start/first-node
Description: Define a Frond node, register it with the runtime, and watch it transition to ready.
A node is one piece of state with a known lifecycle. You write the class; the runtime constructs the instance after `acquire` succeeds and manages its loading, refreshing, and eviction.
This page builds a small resource node — a profile loader — end to end. The authoring chapter covers kinds, drivers, and lifecycle wrappers in detail.
## The shape
A Frond node has two parts:
1. A **spec type** — `Frond.NodeSpec<{ args, key, deps, result, actions }>` — that names everything the runtime needs at the type level.
2. A **class** — `extends Frond.NodeBase` — that carries one `static readonly spec` and any domain methods you add on top of `this.result`.
The shape is the same across kinds (resource, service, facade).
## Define a node
We'll build a resource that loads the current user's profile from an API.
```ts
// src/nodes/profile.ts
type Profile = {
readonly id: string;
readonly name: string;
readonly email: string;
};
type ProfileSpec = Frond.NodeSpec<{
readonly args: { readonly userId: string };
readonly key: Frond.Key.Structure<{ readonly userId: string }>;
readonly result: Profile;
}>;
static readonly spec = Frond.resourceSpec({
tag: Frond.tag("app/profile"),
key: (args) => Frond.Key.structure({ userId: args.userId }),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire(async (ctx): Promise => {
const res = await fetch(`/api/users/${ctx.args.userId}`, {
signal: ctx.signal,
});
if (!res.ok) throw new Error(`profile ${ctx.args.userId}: ${res.status}`);
return res.json();
}),
release: Frond.Driver.Release(() => {
// Nothing to clean up for a plain fetch.
// If you opened a socket, started a timer, or subscribed to anything,
// that'd happen here.
}),
}),
});
get displayName(): string {
return this.result.name || this.result.email;
}
}
```
What each piece does:
- **`tag`** is the human-readable label — what shows up in devtools, error reports, logs. Keep it stable across versions.
- **`key`** is identity. Reads with the same key resolve to the same node; reads with different keys resolve to distinct nodes. `Frond.Key.structure({...})` builds the key from your args; `Frond.Key.singleton()` is for nodes that exist exactly once (a session, a router, a websocket).
- **`acquire`** receives a `ctx` with your `args`, the resolved `deps` (none here), and an `AbortSignal` tied to the node's lifecycle. Pass the signal to `fetch` and the request cancels automatically when the node is released.
- **`release`** runs once when the node leaves the graph. Empty here, but this is where sockets close, timers clear, subscriptions tear down.
- **`displayName`** is a domain method on your node class. The runtime gives you `this.result` once ready; you build whatever surface your callers want on top of it.
## Read the node
Outside React you go through the runtime client directly:
```ts
const profile = runtime.client.node(ProfileNode, { userId: "u_42" });
await profile.ensureReady();
const read = profile.read();
if (read._tag === "Ready") {
console.log(read.node.displayName);
}
```
**`client.node(Spec, args)`** is the way you ask for a node. It doesn't acquire eagerly — it returns a handle. You can hold the handle, subscribe to it, ask for its current state.
**`ensureReady()`** drives the acquisition and resolves when the node is ready or rejects if it failed. For a one-shot script, await it. In React you never call this — the hook does.
**`read()`** returns a tagged union. Only `Ready` exposes `read.node`, the fully-constructed `ProfileNode` instance. Every other public read state carries no `node`.
## What just happened
When you asked for `ProfileNode` with `{ userId: "u_42" }`:
1. The runtime computed the identity key from your args.
2. It looked for an existing node with that key. None existed, so it created an idle slot.
3. `ensureReady()` triggered `acquire`. The runtime called your function with the lifecycle-scoped `ctx`, awaited the promise, and stored the result.
4. It constructed `ProfileNode`, set `this.result`, and transitioned the slot to `Ready`.
5. `read()` returned `{ _tag: "Ready", node: profileInstance }`.
If you ask for the same node again with the same args, you get the same instance — same identity, no re-acquisition. A different `userId` resolves to a separate node.
## Evicting
For now, the node stays in the graph until you evict it, or until you wire it as a dependency under something that evicts (covered in the model chapter).
```ts
await runtime.client.node(ProfileNode, { userId: "u_42" }).evict();
```
Eviction interrupts active work, frees the slot, and removes the node from the graph. If there is a ready resource to close, Frond also runs your `Release` handler; work started in `acquire` that honored the lifecycle signal is cancelled. The next read with the same key starts a fresh acquisition.
---
Next: read this node from React without ever touching `ensureReady` or `read` yourself.
---
# Wire it to React
URL: https://frondruntime.dev/docs/start/wire-to-react
Description: Read a Frond node from a React component using useNode, useNodeState, and Suspense.
The runtime holds the graph; React reads it. Each hook is a subscription against the live runtime, so a component does not fetch, track loading state, or clean up after itself.
This page reads the `ProfileNode` from [your first node](/docs/start/first-node) inside a real component.
Nodes are MobX-backed. Wrap components in `observer` from `mobx-react-lite` when they render node fields, getters, or result-backed data. Without `observer`, Suspense, error boundaries, controls, and hook lifecycle state still work, but MobX field changes and live observation will not reliably update or register. Components that only drive controls don't need it.
## The provider
You mounted it in [Install](/docs/start/install). Here it is again.
```tsx
return (
);
}
```
One provider, at the root. Every hook below reads from this runtime.
## `useNode` — the common case
`useNode` acquires a node and gives you the **ready instance**. It suspends until ready and throws to the nearest error boundary on failure.
```tsx
const ProfilePanel = observer(({ userId }: { userId: string }) => {
const profile = FrondReact.useNode(ProfileNode, { userId });
return
{profile.displayName}
;
});
```
The component has no `if (loading)`, `if (error)`, or `useEffect`. The Suspense boundary above handles loading; the error boundary above handles failure.
```tsx
Couldn't load profile.
}>
Loading…}>
```
If `userId` changes, the hook switches to the new node — same identity rules as outside React. Two `` mounted in different places share the same instance.
## `useNodeState` — when you need the lifecycle
`useNode` returns only the ready node. To render *while* the node is busy (a refresh spinner over the current value, say), or to distinguish stale data from current, use `useNodeState`. It suspends and throws readiness errors exactly like `useNode` — in fact `useNode` returns `useNodeState(...).node` — but it also hands back the node's live operation state.
```tsx
const ProfilePanel = observer(({ userId }: { userId: string }) => {
const state = FrondReact.useNodeState(ProfileNode, { userId });
return (
{state.node.displayName}
{state.busy && }
);
});
```
What you get back:
- **`state.node`** — the ready node instance. The hook suspends until the node is ready, so `state.node` is always ready when the component renders.
- **`state.operation`** — the running operation: `{ _tag: "Idle" }` or `{ _tag: "Running", kind, ... }` where `kind` is `"action"`, `"refresh"`, or `"args"`.
- **`state.busy`** — `true` while any operation is running. Spinner predicate.
- **`state.operationFailure`** — the last operation failure, if any.
- **`state.resultValidity`** — `{ _tag: "Current" | "Stale" }`. Useful for "loaded an hour ago, refreshing now" UI.
Use `useNode` when you want the simple ready-or-Suspense path. Use `useNodeState` when render needs to know the lifecycle.
## `Preload` — acquire before render
`Preload` acquires a set of nodes before its children render. Useful at route boundaries — the route doesn't paint until its data is ready.
```tsx
```
`Preload` suspends just like `useNode`. It doesn't pin nodes alive on its own — it acquires them, then the children read them. Use it to consolidate Suspense boundaries above mixed-data screens.
## Controls without rendering
If a component needs to refresh or evict a node without reading its result, use `useNodeControls`. It doesn't suspend.
```tsx
function RefreshButton({ userId }: { userId: string }) {
const controls = FrondReact.useNodeControls(ProfileNode, { userId });
return ;
}
```
`controls.refresh()`, `controls.evict()`, `controls.releaseResources()` — runtime-level operations. For product behavior (like incrementing a counter or saving a form), call **node actions** instead — those live on the node instance you read with `useNode`.
## What the runtime handles
These are the runtime's responsibility, not the component's:
- Fetching — no `useEffect(() => { fetch(...); return cleanup; }, [userId])`.
- Loading, error, and data state — no `useState` for them.
- Request cancellation — no manual `AbortController`.
- De-duplication — two components reading the same user share one fetch.
- Refresh and invalidation — driven through the node, not a separate library.
The React entry points are the provider, the hooks (`useNode`, `useNodeState`, `useNodeControls`, and the map variants), and `Preload`.
---
You now have a runtime, a node, and a component reading it. The rest of the docs cover the model (eviction cascades, subgraphs), the authoring surface (services, facades, drivers, actions), and recipes (Sentry projection, pagination, Effect escape hatches).
---
# Runtime and graph
URL: https://frondruntime.dev/docs/model/runtime-and-graph
Description: The Frond runtime is one object that owns a graph of keyed nodes. How to create it, how nodes are identified, and how you read them from inside and outside React.
The runtime is a single object that owns a graph of nodes. All loads, refreshes, and evictions happen through it. Create it once at the top of your app.
## Create the runtime
```ts
```
`createRuntime(options?)` returns a `Runtime`. It starts empty — no nodes, no drivers, no fetches. Nodes are created lazily, the first time something asks for one.
Use one runtime per app. The client and the React provider both read through it.
## The graph
The graph is the set of nodes currently alive, plus the dependency edges between them. A node is one slot with an identity, a result once it loads, and a lifecycle the runtime drives. Nodes model your domain objects — a user profile, a websocket connection, a feature-flag client.
Nodes are lazy:
- Ask for a node and it joins the graph.
- Evict it and it is removed, along with its dependents.
The graph holds only what has been asked for and not yet evicted.
## Identity
Every node has a **graph node id** of the form `tag:key`:
- **tag** — names the kind of node (`resources/profile`), set on the spec.
- **key** — derived from args by the spec's `key(args)` function.
Same tag and key produce the same id, which resolves to the same node.
```ts
runtime.client.node(ProfileNode, { userId: "u_42" }).nodeId;
// -> "resources/profile:v1:{\"userId\":\"u_42\"}"
```
Requesting `u_42` from several places resolves to one node — one fetch, one instance. A dependency edge points at an id, so a node depending on the session and a component reading the session resolve to the same node. See [Identity and keys](/docs/model/identity-and-keys).
## Read the graph
### Outside React
`runtime.client.node(spec, args)` returns a handle. Creating the handle resolves the id but does not acquire the node.
```ts
const handle = runtime.client.node(ProfileNode, { userId: "u_42" });
await handle.ensureReady(); // acquire and wait until ready
handle.read(); // current state, no acquire
```
Handle methods:
| Method | Description |
| --- | --- |
| `ensureReady()` | Acquire and resolve when ready; reject on readiness error. |
| `ensure()` | Wire the node into the graph without forcing acquire. |
| `read()` | Read current state synchronously. |
| `refresh()` | Re-acquire while keeping current data visible. |
| `runAction(action, input)` | Run a declared node action. |
| `updateArgs(nextArgs)` | Reconcile args (same id only). |
| `releaseResources(reason)` | Tear down resources, keep the wiring. |
| `evict(mode?, reason?)` | Remove from the graph. Default mode `"selfAndDependents"`. |
### Inside React
Wrap the tree in `FrondProvider`, then read nodes with hooks.
```tsx
;
```
Nodes are MobX-backed, so a component that reads reactive node fields is wrapped in `observer`:
```tsx
const Profile = observer(({ userId }: { userId: string }) => {
const profile = FrondReact.useNode(ProfileNode, { userId });
return
{profile.displayName}
;
});
```
A handle from `runtime.client` and a `useNode` call, both for `u_42`, resolve to the same id and read the same node. See [Wire to React](/docs/start/wire-to-react).
## One owner
The runtime owns every lifecycle fact: readiness, in-flight attempts, refreshes, cancellation, eviction, and liveness. Callers — React, MobX, devtools, tests, scripts — go through it rather than mutating node state directly.
Each node serializes its work through the runtime. Late completion of a superseded attempt does not overwrite newer state, and concurrent operations on one node run in order rather than interleaving. This holds for every caller, so it does not depend on the UI disabling a control.
---
Next: [Identity and keys](/docs/model/identity-and-keys) — how the key half of the id is built. Then the [lifecycle](/docs/model/lifecycle) a node moves through once it has an id.
---
# Identity and keys
URL: https://frondruntime.dev/docs/model/identity-and-keys
Description: A node's id is its tag plus a canonical key derived from its args. How Frond computes the id, the canonicalization rules, and the failure modes.
A node's identity is a **graph node id** of the form `tag:key`. The tag is set on the spec; the key is computed from args every time a node is requested. Same tag and same key produce the same id, which resolves to the same node.
```ts
runtime.client.node(ProfileNode, { userId: "u_42" }).nodeId;
// -> "resources/profile:v1:{\"userId\":\"u_42\"}"
```
## The tag
The tag names the kind of node, not an instance. Set it on the spec with `Frond.tag(value)`.
```ts
static readonly spec = Frond.resourceSpec({
tag: Frond.tag("resources/profile"),
// ...
});
```
- Use path-like values: `resources/profile`, not `resources/profile/u_42`.
- The tag is part of the id. Renaming it changes the id of every node built from the spec.
- Two specs must not share a tag.
## The key
A node declares a `key(args)` function. The runtime runs it, then canonicalizes the result into the string half of the id.
```ts
key: (args) => Frond.Key.structure({ userId: args.userId }),
```
The function picks the parts of args that define identity and returns a branded key. Use `Frond.Key.structure(...)` for JSON-shaped structured keys and `Frond.Key.singleton()` for singleton nodes.
> **Warning:** `key` must be pure and deterministic — no clock, no random, no environment, no Effect services, no async. A non-deterministic key (for example, one that includes `Date.now()`) produces a new id on every call, and therefore a new node and a new fetch.
## Canonicalization
The runtime does not compare key values directly. It serializes the value with `canonicalKey` and compares the resulting string.
| Rule | Behavior |
| --- | --- |
| Object key order | Ignored. Object keys are sorted before stringifying, so `{ a: 1, b: 2 }` and `{ b: 2, a: 1 }` produce the same key. |
| Array order | Significant. `[1, 2]` and `[2, 1]` are different keys. Sort first if order is not part of identity. |
| Allowed values | JSON-shaped only: `string`, finite `number`, `boolean`, `null`, `undefined`, arrays, and plain objects. |
| Prefix | The canonical string starts with `v1:`. |
| Length cap | 2048 characters (`MAX_CANONICAL_KEY_LENGTH`). |
Inputs that cannot be canonicalized throw:
| Input | Error |
| --- | --- |
| A `Date`, `Map`, class instance, or function | `KeyUnsupportedJsonValueError` |
| `NaN`, `Infinity`, or `-Infinity` | `KeyNonFiniteNumberError` |
| A canonical string over 2048 characters | `KeyTooLongError` |
Include only the fields that distinguish the node; embedding whole payloads can exceed the length cap.
## Failure modes
| Mode | Cause | Result | Fix |
| --- | --- | --- | --- |
| Over-keying | A field varies when it should not (timestamp, request nonce, object reference recreated each render). | One logical node fragments into many ids, each with its own fetch and cache. | Key on the stable identifier only. |
| Under-keying | A distinguishing field is omitted (paginated list keyed on filter but not page). | Two distinct nodes collapse onto one id; the second read gets the first node's result. | Put every field that defines a distinct instance in the key. |
See [Args and deps](/docs/authoring/args-and-deps) for declaring args and the key function, and [Dependencies](/docs/model/dependencies) for how one node's id becomes another's edge.
---
Next: [Lifecycle](/docs/model/lifecycle) — the states a node moves through once it has an id.
---
# Lifecycle
URL: https://frondruntime.dev/docs/model/lifecycle
Description: The public states a node exposes — idle, pending, ready, error — plus how operations run on ready data without leaving readiness.
A node has two layers of state. **Readiness** is whether the node has loaded data. **Operations** (action, refresh, args reconciliation) run on a ready node without changing its readiness. The runtime owns every transition; callers request them through the handle.
`handle.read()` returns the current state as a tagged union:
```ts
const read = runtime.client.node(ProfileNode, { userId: "u_42" }).read();
read._tag; // "Unwired" | "Idle" | "Pending" | "Ready" | "Error"
```
## States
| `_tag` | Meaning | Has result |
| --- | --- | --- |
| `Unwired` | Not in the graph. | No |
| `Idle` | Wired, no readiness attempt in flight, no ready data. | No |
| `Pending` | Readiness attempt in flight. | No |
| `Ready` | Result and deps available for consumer reads. | Yes |
| `Error` | Readiness attempt failed. | No |
`Idle`, `Pending`, `Ready`, and `Error` also carry `operation`, `busy`, and `operationFailure`. `Ready` carries `resultValidity`.
Internal states such as `Booting`, `Expired`, `Invalid`, and `Unavailable` are kept on raw diagnostic surfaces: unsafe reads, snapshots, diagnostics, tests, and devtools. Product code should use the smaller public read union above.
> **Note:** A `result` of `undefined` is valid data. Readiness is determined by the state tag, not by whether `result` is present.
## Readiness
`ensureReady()` crosses the readiness barrier. It resolves with the ready read when the node becomes `Ready`, and rejects when readiness fails.
```ts
await handle.ensureReady();
```
```txt
Idle + ensureReady -> Pending -> success: Ready
-> failure: Error
Pending + ensureReady -> joins the in-flight attempt
Error + ensureReady -> Pending (retry)
```
Readiness waits for dependency readiness first. A node cannot become `Ready` until every node it depends on is `Ready`. If a dependency fails, the dependent enters `Error` with that failure. See [Dependencies](/docs/model/dependencies).
## Operations
Operations run against a `Ready` node and keep the current result visible while they run. While an operation runs, `busy` is `true` and `operation` is `{ _tag: "Running", kind }`.
| Operation | Trigger | Kind |
| --- | --- | --- |
| Action | `handle.runAction(action, input)` | `"action"` |
| Refresh | `handle.refresh()` | `"refresh"` |
| Args reconciliation | `handle.updateArgs(nextArgs)` | `"args"` |
```txt
Ready + operation -> running (result stays visible)
-> success: Ready with committed result
-> failure: Ready with operationFailure
```
Operation failure is not readiness failure. A failed action or refresh leaves the node `Ready` with `operationFailure` set; it does not move the node to `Error`.
`refresh()` requires displayable ready data. It returns a `Failure` result when the node is idle, pending, errored, invalid, or expired. On failure it rolls back to the previous result. A runtime-managed action result commit is applied only when the action succeeds; direct MobX mutations inside a failing action are author-owned.
`updateArgs()` reconciles args only when they resolve to the same node id. Args that change the id are rejected; request the other node instead. See [Identity and keys](/docs/model/identity-and-keys).
## Expiry
Result validity is `Current`, `Stale`, or `Expired`, set by the node's validity policy.
| Validity | Meaning |
| --- | --- |
| `Current` | Within the fresh window. |
| `Stale` | Past the stale threshold, still displayable. |
| `Expired` | Past the expiry threshold; not exposed as an ordinary result. |
`Stale` data still reads as `Ready`. `Expired` is a validity state, not a renderable `Ready` result. Expired data is hidden from ordinary reads and re-acquires when an active consumer needs the node. Expiry does not remove the node from the graph. See [Result validity](/docs/model/result-validity).
## Release and eviction
| Action | Method | Effect |
| --- | --- | --- |
| Release | `handle.releaseResources(reason)` | Stops resources, runs disposers, returns to `Idle`. Keeps graph wiring. |
| Eviction | `handle.evict(mode?, reason?)` | Interrupts active work, cleans up, removes the graph record and edges. |
Release keeps the node in the graph; eviction removes it. See [Eviction and release](/docs/model/eviction-and-release).
## Observing in React
`useNodeState` exposes the same fields for render:
```tsx
const state = FrondReact.useNodeState(ProfileNode, { userId });
state.busy; // operation in flight
state.operation; // { _tag: "Idle" } | { _tag: "Running", kind }
state.operationFailure; // last operation failure, if any
state.resultValidity; // { _tag: "Current" | "Stale" }
```
See [Wire to React](/docs/start/wire-to-react).
---
Next: [Dependencies](/docs/model/dependencies) — how readiness flows across edges.
---
# Dependencies
URL: https://frondruntime.dev/docs/model/dependencies
Description: How a node declares dependencies on other nodes, how readiness flows across edges, and how eviction cascades to dependents.
A node declares the other nodes it needs. Each declaration is a graph edge pointing at a dependency's node id. The runtime resolves dependencies to ready node instances and passes them to the driver.
Request the leaf resources and readiness loads by layer: `auth/session` first, then the services that depend on it, then the resources on top. A node cannot become ready until every node it depends on is ready.
## Declaring dependencies
Declare dependencies on the spec with `Frond.dependencies`. Each entry is a `Frond.dep(spec, args)`.
```ts
static readonly spec = Frond.facadeSpec({
tag: Frond.tag("dashboard/summary"),
key: () => Frond.Key.singleton(),
dependencies: Frond.dependencies(() => ({
profile: Frond.dep(CurrentUserProfileNode, Frond.Args.none),
weather: Frond.dep(WeatherNode, Frond.Args.none),
})),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) =>
buildDashboard(ctx.deps.profile.result, ctx.deps.weather.result)
),
}),
});
```
- `Frond.dependencies((args) => ({ ... }))` takes the node's args and returns a record of dependencies.
- `Frond.dep(spec, args)` names one dependency: which node spec, and the args to resolve its id.
- `Frond.Args.none` (`{}`) is the args for a node that takes none.
The driver receives resolved dependencies as `ctx.deps`. Each entry is the dependency's ready node instance, so `ctx.deps.profile.result` reads the current-user profile node's result. The keys match the declaration (`profile`, `weather`).
## Edges
A dependency resolves to a node id (`tag:key`) the same way any read does. Two nodes that depend on `AuthSessionNode` with `Frond.Args.none` point at the same node id, so they share one session node, one fetch, one instance.
```ts
dependencies: Frond.dependencies(() => ({
session: Frond.dep(AuthSessionNode, Frond.Args.none),
})),
```
See [Identity and keys](/docs/model/identity-and-keys) for how ids are computed.
## Readiness flows across edges
A node waits for dependency readiness before its own acquire runs. It cannot become `Ready` until every dependency is `Ready`.
```txt
profile: Ready ─┐
├─> dashboard: acquire runs -> Ready
weather: Ready ─┘
```
If a dependency fails readiness, the dependent enters `Error` with that failure instead of acquiring. Sibling dependency failures aggregate into a typed dependency failure, so parallel errors are not lost. See [Lifecycle](/docs/model/lifecycle).
## Static topology
The dependency set is part of the node's wiring, fixed for a given node id. `updateArgs()` reconciles args only when they resolve to the same id and the same dependency set. Args that would change the dependency topology fail the update; request the other node instead.
A cycle in the dependency graph, a duplicate tag, or an invalid dependency declaration is a planning failure — the node becomes invalid rather than acquiring.
## Eviction cascades to dependents
Evicting a node evicts its reverse dependency closure: every node that depends on it, directly or transitively.
```ts
await runtime.client.node(AuthSessionNode, Frond.Args.none).evict();
// evicts the session node and every node that depends on it
```
| Mode | Effect |
| --- | --- |
| `selfAndDependents` | Evict the node and its reverse dependency closure. Default. |
| `dependents` | Evict the reverse closure, leave the node itself. |
Eviction orders deeper dependents before the nodes they depend on. See [Eviction and release](/docs/model/eviction-and-release).
---
Next: [Eviction and release](/docs/model/eviction-and-release) — what cleanup runs, and how the two differ.
---
# Eviction and release
URL: https://frondruntime.dev/docs/model/eviction-and-release
Description: Release cleans a node's resources but keeps it in the graph; eviction removes the node and its edges. What each runs, in what order, and the state left behind.
Release and eviction both tear down a node's resources. They differ in what survives.
| | Release | Eviction |
| --- | --- | --- |
| Method | `releaseResources(reason?)` | `evict(mode?, reason?)` |
| Returns | `Promise` | `Promise` |
| Graph identity | Kept | Removed |
| Graph edges | Kept | Removed |
| Ready data | Cleared | Cleared |
| Next read | `Idle`, can reacquire | `Unwired`, must rewire |
| Use when | Identity still valid; you'll read it again | Identity is gone; drop it and its dependents |
## Release
Release cleans a node's resources and returns it to `Idle`, keeping its place in the graph.
```ts
await runtime.client.node(ProfileNode, { userId: "u_42" }).releaseResources();
```
In order, release:
1. Stops the active live resource, if one exists.
2. Runs the driver release hook, if configured.
3. Runs the node's disposers.
4. Closes and drops the current ready node instance.
5. Transitions the cell to `Idle` (or a release-failure projection).
Graph identity, the graph record, and edges stay. A released node can be acquired again with `ensureReady()` without rewiring. Missing release hooks are no-ops; release on a node with no cell is a no-op.
## Eviction
Eviction removes a node and its dependents from the graph.
```ts
const result = await runtime.client.node(SessionNode, Frond.Args.none).evict();
result.nodeIds; // ids that were removed
result.failures; // cleanup failures, if any
```
In order, eviction:
1. Computes the dependency closure for the requested mode.
2. Interrupts active actor work for each node in the closure.
3. Runs cleanup, best-effort.
4. Removes the actor entries, graph node records, and edges touching evicted nodes.
5. Emits one eviction event with the evicted ids and cleanup failures.
`evict()` returns an `EvictResult` of `{ nodeIds, failures }` — the ids removed and any failures collected during cleanup.
### Modes
```ts
await handle.evict("selfAndDependents"); // default
await handle.evict("dependents");
```
| Mode | Effect |
| --- | --- |
| `selfAndDependents` | Evict the node and its reverse dependency closure. Default. |
| `dependents` | Evict the reverse closure, leave the node itself. |
The reverse dependency closure is every node that depends on the target, directly or transitively. Eviction orders deeper dependents before the nodes they depend on. See [Dependencies](/docs/model/dependencies).
> **Tip:** On logout, evict the session node. Every user-scoped node that declared a session dependency is automatically removed with it — one call clears the entire user subgraph.
## Interruption
Eviction interrupts active work on each evicted node. Interrupted work receives the eviction cancellation reason unless the request supplies a more specific one. Late completion of an interrupted acquire, action, refresh, or args reconciliation does not commit after the graph record is removed.
## Cleanup failures
Cleanup is best-effort. A failure stopping a live resource or running a disposer does not abort the rest of the cleanup, and does not by itself keep the node's identity.
- **Release** collects failures from live-resource stop, driver release, release-hook disposers, and normal disposers. `releaseResources()` still resolves `void`; the runtime emits the retained cleanup failure through events and diagnostics. The projected released cell retains the first failure in execution order as its primary failure.
- **Eviction** returns all cleanup failures in `EvictResult.failures`.
## After the call
| | Reads project | `ensureReady()` |
| --- | --- | --- |
| After release | `Idle` (or release failure) | Reacquires |
| After eviction | `Unwired` / missing | Wires and acquires a new node |
## Idempotency
Releasing a node with no cell is a no-op. Evicting a missing root is ignored — there is no record to remove. Repeated eviction of an already-removed node does not rerun cleanup.
---
Next: [Result validity](/docs/model/result-validity) — how a result moves from current to stale to expired.
---
# Result validity
URL: https://frondruntime.dev/docs/model/result-validity
Description: Whether a node's committed result may be shown — current, stale, or expired — how the policy is set, and how expired data reloads through readiness.
Result validity is whether a node's committed result may be exposed to ordinary reads. A ready node carries a result and a validity alongside it. Validity attaches to the result, not to identity, topology, or readiness.
## States
```ts
type ResultValidity =
| { readonly _tag: "Current"; readonly currentAt?: number }
| { readonly _tag: "Stale"; readonly staleAt: number }
| { readonly _tag: "Expired"; readonly expiredAt: number };
```
| State | Exposed to ordinary reads |
| --- | --- |
| `Current` | Yes |
| `Stale` | Yes |
| `Expired` | No |
`Stale` data still reads as `Ready`. It is displayable; it is just past its fresh window. `Expired` data is hidden from ordinary reads and reloads through readiness.
## Policies
A driver declares how validity is decided with the `resultValidity` field. Omitted means `Static`.
| Policy | Behavior |
| --- | --- |
| `Static` | Each successful acquire, refresh, or action commits `Current`. The default. |
| `Manual` | The runtime does not time-age the result. The driver sets validity. |
| `TimeBound` | The runtime derives `Current`, `Stale`, or `Expired` from the loaded time and the clock. |
```ts
driver: Frond.Driver.Async({
resultValidity: {
_tag: "TimeBound",
staleAfter: "30 seconds",
expireAfter: "5 minutes",
},
acquire: Frond.Driver.Acquire((ctx) => fetchProfile(ctx.args.userId)),
}),
```
With `TimeBound`, the runtime ages the result on its own — no driver code runs to mark it stale or expired.
## Setting validity manually
Under `Manual` (or any policy where the driver knows better than the clock), commit validity with the result using `Frond.resultCommit`, or set it from the driver context.
```ts
return Frond.resultCommit(prices, {
validity: { _tag: "Current", currentAt: Date.now() },
});
```
```ts
// inside an action or refresh
ctx.setResultValidity({ _tag: "Stale", staleAt: Date.now() });
```
See [Drivers](/docs/authoring/drivers) for the full driver context.
## Expired reloads through readiness
An expired result is not a failure and not an eviction. Ordinary reads hide it instead of exposing it as `Ready` data. Active readiness paths then reload the node.
When readiness runs again, it runs dependency readiness and acquire again, and commits the new result on success. The graph record, id, key, args, dependency edges, and live leases are preserved — only ordinary result exposure is cleared while the expired result is not displayable.
In React, `useNode` does not return expired data. It schedules readiness and throws a Suspense promise, so the nearest boundary shows its fallback while the node reloads.
## Refresh and validity
| Validity | `refresh()` |
| --- | --- |
| `Current` | Runs. |
| `Stale` | Runs. |
| `Expired` | Returns a `Failure` result with `ResultExpired`. |
A stale refresh that fails keeps the stale result visible and records an operation failure. Expired data does not recover through refresh — it recovers through readiness. See [Lifecycle](/docs/model/lifecycle).
## Dependencies do not cascade
Each node owns its own validity. A dependency going `Stale` does not invalidate the nodes that depend on it. A node acquiring against an expired dependency fails through a typed dependency failure rather than silently reading expired data.
## Reading validity
`handle.read()` exposes displayable data as `Ready`. `Current` and `Stale` results are displayable; `Expired` is hidden from ordinary reads and recovered through readiness. In React, `useNodeState` exposes displayable validity directly:
```tsx
const state = FrondReact.useNodeState(ProfileNode, { userId });
state.resultValidity; // { _tag: "Current" | "Stale" }
```
Use it to render "loaded a while ago, refreshing" affordances over stale data. Raw diagnostic surfaces can still show expired validity when you need to inspect why a node re-entered readiness.
---
Next: [Liveness](/docs/model/liveness) — how the runtime keeps a node's live resource running while it is observed.
---
# Liveness
URL: https://frondruntime.dev/docs/model/liveness
Description: How Frond starts driver live work from MobX observation or manual leases, and why React mount presence is not liveness.
Liveness is demand for a node's driver **live work** — a running subscription, socket, or poll that pushes updates into the result. A node is live when something is observing it in a way that needs that work running. Liveness is separate from readiness: a node can be ready without being live.
Liveness is not React mount presence, not runtime retention, not event subscription, and not snapshot inspection. Only two things create it.
## Sources
| Source | Created by |
| --- | --- |
| `mobx` | Observing a MobX node result in a reactive context. |
| `manual` | A live lease acquired through the runtime client. |
When demand exists, the runtime runs the driver's live resource. When the last demand goes away, it stops the resource.
## Manual leases
Acquire a lease to hold a node live without observing its result — useful outside React, or when you need live work independent of rendering.
```ts
const lease = await runtime.client
.node(WeatherNode, Frond.Args.none)
.acquireLiveLease("manual", { cities: ["Lisbon"] });
// later
await lease.dispose();
```
The scope (`{ cities: ["Lisbon"] }`) must be a JSON-shaped value. A lease records demand only — it does not acquire, refresh, release, or evict the node, and does not imply readiness. Dispose it to drop the demand.
## MobX observation
Reading a node's result inside a MobX reaction creates `mobx` demand automatically. Because Frond's node model is MobX-backed, an `observer` component that reads a live result registers observation, and the runtime starts the driver's live resource while that observation is active.
This is why React components that read node fields are wrapped in `observer` — see [Wire to React](/docs/start/wire-to-react). A component that mounts but never reads a live field creates no liveness; the observation is what registers demand, not the mount.
## A live node
A driver delivers live work with `Frond.Driver.Live`. It is optional: a node with no live resource still tracks demand, but there is nothing to start. `acquire` seeds the result; the live resource runs only while there is demand and pushes updates into that result.
```ts
type WeatherResult = { temperature: number };
type WeatherSpec = Frond.NodeSpec<{
args: Frond.Args.None;
key: Frond.Key.Singleton;
result: WeatherResult;
}>;
// stand-in for a websocket/SSE subscription
type WeatherFeed = { close: () => void };
declare function openWeatherFeed(onTemperature: (temperature: number) => void): WeatherFeed;
static readonly spec = Frond.resourceSpec({
tag: Frond.tag("weather/current"),
key: () => Frond.Key.singleton(),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire(() => observable({ temperature: 18 })),
live: Frond.Driver.Live, WeatherFeed>({
start: (ctx) =>
openWeatherFeed((temperature) => {
runInAction(() => {
ctx.node.result.temperature = temperature;
});
}),
stop: (_ctx, feed) => feed.close(),
}),
}),
});
}
```
`start` opens the feed and returns it as the live resource; `stop` closes it. The feed writes into `ctx.node.result`, which is observable, so each push re-renders observers.
Read the node in an `observer` component:
```tsx
const CurrentWeather = observer(() => {
const weather = FrondReact.useNode(WeatherNode, Frond.Args.none);
return {weather.result.temperature};
});
```
Reading `weather.result.temperature` inside the `observer` registers `mobx` observation. While `` is mounted and reading, the node has demand, so the runtime calls `start` and the feed pushes temperatures into the result. When the component unmounts, observation ends, demand drops, and the runtime calls `stop`. The runtime starts and stops the resource; the component writes no `useEffect` or subscription.
### Demand changes
`start` and `update` receive the active demand snapshot. The runtime drives the resource as demand changes:
| Demand change | Effect |
| --- | --- |
| Goes inactive | `stop` the resource (`DemandInactive`). |
| Stays equivalent | No-op. |
| Changes, `update` provided | Call `update`. |
| Changes, no `update` | `stop` (`DemandChanged`), then `start` a new resource. |
Live cleanup belongs to `stop`, not to acquire/operation disposers. Release, eviction, graph stop, and expiry all stop the active resource through the same path.
## React presence is not liveness
A mounted React component does not, by itself, create live demand. To run live work in React, either read a MobX result that reports observation, or acquire a manual lease through the runtime client. Reads and subscriptions are presentation; liveness is driver demand.
---
Next: [Spec and class](/docs/authoring/spec-and-class) — how to author a node.
---
# Spec and class
URL: https://frondruntime.dev/docs/authoring/spec-and-class
Description: A Frond node is a class extending NodeBase plus a static spec. The spec configures identity and loading; the class is the typed surface consumers read.
A node is a class that extends `Frond.NodeBase` and carries a static `spec`. The spec is configuration — tag, key, dependencies, driver. The class is the instance consumers read: `this.result`, `this.deps`, `this.args`, plus any getters and methods you add.
```ts
type ProfileResult = { displayName: string; email: string };
type ProfileSpec = Frond.NodeSpec<{
args: { userId: string };
key: Frond.Key.Structure<{ userId: string }>;
result: ProfileResult;
}>;
static readonly spec = Frond.resourceSpec({
tag: Frond.tag("resources/profile"),
key: (args) => Frond.Key.structure({ userId: args.userId }),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) => fetchProfile(ctx.args.userId)),
}),
});
get displayName(): string {
return this.result.displayName;
}
}
```
## The spec type
`Frond.NodeSpec` is a type-only descriptor of the node's shape. Declare the fields your node uses.
| Field | Meaning |
| --- | --- |
| `args` | The input passed when requesting the node. |
| `key` | The value the `key(args)` function returns. |
| `result` | The type of `this.result` once ready. |
| `deps` | The declared dependencies record. |
| `actions` | The action contracts the node exposes. |
A node with no input uses `Frond.Args.None` for `args` and `Frond.Key.Singleton` for `key`.
## The spec value
The static `spec` is built with a kind constructor — `resourceSpec`, `serviceSpec`, or `facadeSpec`. See [Kinds](/docs/authoring/kinds). All take the same input:
| Field | Required | Meaning |
| --- | --- | --- |
| `tag` | Yes | `Frond.tag(value)` — names the kind of node. |
| `key` | Yes | `(args) => Frond.Key.structure(...)` or `Frond.Key.singleton()` — derives the instance key. Pure and deterministic. |
| `dependencies` | No | `Frond.dependencies((args) => ({ ... }))`. Omitted means no dependencies. |
| `driver` | Yes | How the node loads and updates. See [Drivers](/docs/authoring/drivers). |
`tag` and `key` together produce the node's id. See [Identity and keys](/docs/model/identity-and-keys).
## The class
`Frond.NodeBase` gives every node a typed read surface. You extend it and add domain logic.
| Member | Type | Description |
| --- | --- | --- |
| `this.result` | spec result | The loaded result. Reading it registers observation. |
| `this.deps` | resolved deps | The ready dependency instances. |
| `this.args` | spec args | The args this node was requested with. |
| `this.actions` | action facade | Callable actions. See [Actions](/docs/authoring/actions). |
| `this.nodeId` | `NodeId` | The graph node id. |
| `this.tag` | `string` | The node's tag. |
Add getters and methods that compute from `this.result`, `this.deps`, and `this.args`. They are MobX-derived, so a component reading them inside an `observer` re-renders when the underlying result changes.
```ts
static readonly spec = Frond.resourceSpec({
/* ... */
});
get initials(): string {
return this.result.displayName
.split(" ")
.map((part) => part[0])
.join("");
}
}
```
The runtime constructs the instance only once the node is ready, so inside the class `this.result` and `this.deps` are always present. To run cleanup when the runtime closes the node, register it with the protected `this.onRuntimeClose(disposer)`.
---
Next: [Kinds](/docs/authoring/kinds) — resource, service, and facade, and when to pick which.
---
# Kinds
URL: https://frondruntime.dev/docs/authoring/kinds
Description: Resource, service, facade, and node — four spec constructors that share one shape. The kind labels intent and shows in diagnostics; it does not change runtime behavior.
A node spec is built with one of four kind constructors. They take the same input and produce nodes that behave identically at runtime. The kind is a label: it states what the node is for and appears in diagnostics (`resource:resources/profile`). Pick the one that matches the node's intent.
```ts
Frond.resourceSpec({ /* ... */ });
Frond.serviceSpec({ /* ... */ });
Frond.facadeSpec({ /* ... */ });
Frond.nodeSpec({ /* ... */ });
```
## Choosing a kind
| Kind | Constructor | Use for |
| --- | --- | --- |
| Resource | `resourceSpec` | Data that loads and can refresh or expire — a profile, a list, a fetched document. |
| Service | `serviceSpec` | A long-lived capability other nodes depend on — a config client, a transport, an auth session. |
| Facade | `facadeSpec` | A node whose result composes its dependencies — an overview built from other nodes. |
| Node | `nodeSpec` | The neutral default when none of the above fit. |
The distinction is for you and your diagnostics, not the runtime. A resource and a service with the same tag and key would still resolve to the same node — kind is not part of identity, and it does not change readiness, refresh, eviction, or liveness.
## Resource
The common case: a node that owns a result loaded from somewhere, refreshable and subject to result validity.
```ts
static readonly spec = Frond.resourceSpec({
tag: Frond.tag("resources/profile"),
key: (args) => Frond.Key.structure({ userId: args.userId }),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) => fetchProfile(ctx.args.userId)),
}),
});
```
## Service
A capability that other nodes depend on and that tends to live for the session — configuration, a websocket transport, an auth client. Often a singleton.
```ts
static readonly spec = Frond.serviceSpec({
tag: Frond.tag("services/config"),
key: () => Frond.Key.singleton(),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire(() => loadConfig()),
}),
});
```
## Facade
A node whose result is assembled from its dependencies, presenting one combined view.
```ts
static readonly spec = Frond.facadeSpec({
tag: Frond.tag("facades/account"),
key: () => Frond.Key.singleton(),
dependencies: Frond.dependencies(() => ({
profile: Frond.dep(ProfileNode, { userId: "u_42" }),
billing: Frond.dep(BillingNode, { userId: "u_42" }),
})),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) =>
mergeAccount(ctx.deps.profile.result, ctx.deps.billing.result)
),
}),
});
```
---
Next: [Drivers](/docs/authoring/drivers) — the acquire, refresh, action, and live hooks, and the context they run with.
---
# Drivers
URL: https://frondruntime.dev/docs/authoring/drivers
Description: The driver is how a node loads and updates — acquire, refresh, release, live, and actions, plus the context they run with and how the AbortSignal is wired.
The driver is the side-effect implementation behind a node. It loads the result, refreshes it, releases resources, and runs actions. Every node spec has exactly one driver.
```ts
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) => fetchProfile(ctx.args.userId)),
refresh: Frond.Driver.Refresh(async (ctx) => {
ctx.setResult(await fetchProfile(ctx.args.userId));
}),
release: Frond.Driver.Release((ctx) => ctx.node.result.close()),
}),
```
## Async or Effect
Two flavors, same hook set:
| Constructor | Hooks return | Use when |
| --- | --- | --- |
| `Frond.Driver.Async` | a value or a `Promise` | Your code is `async`/`await` or promise-based. |
| `Frond.Driver.Effect` | an `Effect` | Your code is written in Effect. |
The hooks are the same either way:
| Hook | Required | Purpose |
| --- | --- | --- |
| `acquire` | Yes | Produce the initial result. |
| `refresh` | No | Re-load a ready node's result. |
| `release` | No | Clean up resources. |
| `live` | No | Run live work while observed. See [Liveness](/docs/model/liveness). |
| `actions` | No | Mediated methods on the node. See [Actions](/docs/authoring/actions). |
| `resultValidity` | No | Validity policy. See [Result validity](/docs/model/result-validity). |
## acquire
The only required hook. It produces the result the node becomes ready with. Return the result directly, a `Promise` of it, or a `Frond.resultCommit(...)` to attach metadata.
```ts
acquire: Frond.Driver.Acquire((ctx) => fetchProfile(ctx.args.userId)),
```
`acquire` reads `ctx.args` and `ctx.deps`. It does not get `ctx.node` — the node instance does not exist until acquire returns the result it is built from. If acquire throws or rejects, the node enters readiness error. See [Lifecycle](/docs/model/lifecycle).
## refresh
Re-loads a ready node in the background while the current result stays visible. Commit the new result with `ctx.setResult`.
```ts
refresh: Frond.Driver.Refresh(async (ctx) => {
ctx.setResult(await fetchProfile(ctx.args.userId));
}),
```
Refresh runs only on a node with displayable ready data, and rolls back to the previous result on failure. A node with no `refresh` hook rejects refresh requests.
## release
Runs when the runtime releases the node's resources. Use it to close what `acquire` opened — connections, timers, subscriptions.
```ts
release: Frond.Driver.Release((ctx) => ctx.node.result.close()),
```
Cleanup registered with `ctx.disposers.add(fn)` during acquire runs on release too; the `release` hook is for teardown the disposers do not cover. Release is best-effort: a throwing release is recorded but does not block the rest of cleanup. See [Eviction and release](/docs/model/eviction-and-release).
## The context
Each hook receives a `ctx`. The fields available depend on the hook.
| Field | acquire | refresh / action | release | Description |
| --- | --- | --- | --- | --- |
| `args` | ✓ | ✓ | | The node's args. |
| `deps` | ✓ | ✓ | | Resolved dependency instances. |
| `node` | | ✓ | ✓ | The node instance. |
| `signal` | ✓ | ✓ | ✓ | `AbortSignal` for this work. |
| `disposers` | ✓ | ✓ | ✓ | Register cleanup with `disposers.add(fn)`. |
| `signals` | ✓ | ✓ | | Runtime signal access. |
| `setResult` | ✓ | ✓ | | Commit a new result. |
| `setResultValidity` | ✓ | ✓ | | Set result validity. |
| `patchResult` | ✓ | ✓ | | Mutate the current result in place. |
| `refreshDep` | | ✓ | | Re-acquire a single dependency. |
Effect drivers add `ctx.tryPromise((signal) => run(signal))` to bridge a promise into Effect with the abort signal wired.
## AbortSignal
`ctx.signal` aborts when the work is cancelled — a newer attempt supersedes this one, or the node is released or evicted. Pass it to anything cancellable:
```ts
acquire: Frond.Driver.Acquire((ctx) =>
fetch(`/api/profile/${ctx.args.userId}`, { signal: ctx.signal }).then((r) => r.json())
),
```
In Effect drivers, `ctx.tryPromise` wires the signal for you:
```ts
acquire: Frond.Driver.Acquire((ctx) =>
ctx.tryPromise((signal) => fetch(`/api/profile/${ctx.args.userId}`, { signal }))
),
```
For cleanup that is not tied to a single fetch, register it with `ctx.disposers.add(fn)` and the runtime runs it on release.
---
Next: [Args and deps](/docs/authoring/args-and-deps) — typing a node's input and declaring what it depends on.
---
# Args and deps
URL: https://frondruntime.dev/docs/authoring/args-and-deps
Description: Typing a node's args, deriving its key, and declaring the nodes it depends on — including dependencies that vary with args.
Args are a node's typed input. They feed three things: the `key` function that derives identity, the `dependencies` resolver, and the driver context. Dependencies are the other nodes this node reads, declared as graph edges.
## Args
Declare the args type in the spec shape. The same type is what callers pass when they request the node.
```ts
type ProfileSpec = Frond.NodeSpec<{
args: { userId: string };
key: Frond.Key.Structure<{ userId: string }>;
result: ProfileResult;
}>;
```
```ts
runtime.client.node(ProfileNode, { userId: "u_42" });
```
A node that takes no input uses `Frond.Args.None` in the shape and `Frond.Args.none` (`{}`) at the call site.
```ts
type ConfigSpec = Frond.NodeSpec<{
args: Frond.Args.None;
key: Frond.Key.Singleton;
result: ConfigResult;
}>;
```
Inside the driver, args are on `ctx.args`. Inside the class, they are on `this.args`.
## The key function
`key(args)` returns the value that becomes the node's identity key. It must be pure and deterministic — no clock, random, environment, or async. Pick out the args that distinguish one instance from another.
```ts
key: (args) => Frond.Key.structure({ userId: args.userId }),
```
A structured-args node returns `Frond.Key.structure(...)`; a no-args node returns `Frond.Key.singleton()`. See [Identity and keys](/docs/model/identity-and-keys) for canonicalization and the failure modes.
## Declaring dependencies
`Frond.dependencies` takes a resolver from args to a record of `Frond.dep(spec, args)` entries. The record keys are how you read each dependency.
```ts
dependencies: Frond.dependencies((args) => ({
session: Frond.dep(SessionNode, Frond.Args.none),
profile: Frond.dep(ProfileNode, { userId: args.userId }),
})),
```
A dependency's args can derive from this node's args, as `profile` does above. The dependency resolves to a node id the same way any read does, so two nodes depending on `ProfileNode` with the same `userId` share one instance. See [Dependencies](/docs/model/dependencies) for edges, readiness, and eviction.
The dependency set is part of the node's wiring. `updateArgs()` must resolve to the same dependency set; args that would change which nodes are depended on fail the update.
## Reading dependencies
In the driver, `ctx.deps.` is the resolved dependency — the ready node instance, not a raw value. Read its result through `.result`.
```ts
acquire: Frond.Driver.Acquire((ctx) =>
buildAccount(ctx.deps.session.result, ctx.deps.profile.result)
),
```
In the class, the same instances are on `this.deps`.
```ts
get accountId(): string {
return this.deps.profile.result.accountId;
}
```
To re-acquire a single dependency from a refresh or action, use `ctx.refreshDep("profile")`.
---
Next: [Actions](/docs/authoring/actions) — methods the runtime mediates against a ready node.
---
# Actions
URL: https://frondruntime.dev/docs/authoring/actions
Description: Actions are methods the runtime mediates against a ready node — declared as contracts, implemented in the driver, called through node.actions.
An action is a method on a node that the runtime runs against ready data. It is how a node mutates: sign in, rename, add to cart. You declare the contract in the spec, implement it in the driver, and call it through `node.actions`.
## Declare the contract
Add an `actions` field to the spec shape. Each entry is a `Frond.ActionContract`.
```ts
type ProfileSpec = Frond.NodeSpec<{
args: { userId: string };
key: Frond.Key.Structure<{ userId: string }>;
result: ProfileResult;
actions: {
rename: Frond.ActionContract<{ name: string }, ProfileResult>;
refresh: Frond.ActionContract;
};
}>;
```
`Input` is the argument the caller passes; use `void` for none. `Output` is what the action returns.
## Implement in the driver
Implement each action under the driver's `actions` field with `Frond.Driver.Action`. The handler receives the driver context and the input.
```ts
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) => fetchProfile(ctx.args.userId)),
actions: {
rename: Frond.Driver.Action(async (ctx, input) => {
const next = await saveName(ctx.args.userId, input.name);
ctx.setResult(next);
return next;
}),
},
}),
```
The action context is the full driver context — `ctx.args`, `ctx.deps`, `ctx.node`, `ctx.signal`, `ctx.setResult`. Commit a new result with `ctx.setResult`, and return the action's output.
## Call the action
In React, `useNode` gives you the ready instance; its actions are on `node.actions`, each returning a `Promise` of its output and throwing on failure.
```tsx
const profile = FrondReact.useNode(ProfileNode, { userId });
await profile.actions.rename({ name: "Ada" }); // Promise
```
An action with `void` input takes no argument: `profile.actions.refresh()`.
Outside React, call through the handle with `runAction`. It returns a tagged `ActionResult` instead of throwing.
```ts
const handle = runtime.client.node(ProfileNode, { userId: "u_42" });
const result = await handle.runAction("rename", { name: "Ada" });
// result: { _tag: "Success", nodeId, value } | { _tag: "Failure", nodeId, error }
```
## Actions are operations
An action runs as a background operation against a ready node. The current result stays visible while it runs, the runtime serializes it through the node, and a failure is recorded as an operation failure rather than poisoning readiness. See [Lifecycle](/docs/model/lifecycle).
Because every action routes through the runtime, it cannot run on a node that is not ready, and two actions cannot interleave on the same node. The runtime enforces this regardless of whether the UI disables a control while the action is in flight.
## Actions vs MobX actions
Frond nodes are MobX-backed objects. Ordinary MobX mutation still works: a method on your node can call `runInAction`, or a MobX `action`, and mutate observable result-backed state. That is valid MobX code, and it can be perfectly fine for local synchronous state that you intentionally keep outside runtime policy.
The recommendation changes when the work is asynchronous, can fail, talks to a server, should be serialized through the node, or needs runtime diagnostics. Use Frond actions for that boundary. The runtime gives those calls graph context, per-node admission, serialization, cancellation, timeout handling, result commit rules, and `operationFailure`.
You can put server work directly on the node body, but it is a valid escape hatch with tradeoffs, not the recommended Frond path:
Avoid
```ts
async rename(name: string) {
const next = await saveName(this.args.userId, name);
runInAction(() => {
this.result.displayName = next.displayName;
});
}
}
```
That can work because it is just MobX and JavaScript. The tradeoff is that the runtime does not mediate the async work: it cannot serialize the call with other node operations, record an operation failure, attach graph diagnostics, apply driver timeouts, or prevent late work from committing over newer state. Use it only when you accept those constraints.
Put async product work in a Frond action:
Recommended
```ts
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) => fetchProfile(ctx.args.userId)),
actions: {
rename: Frond.Driver.Action(async (ctx, input) => {
const next = await saveName(ctx.args.userId, input.name);
ctx.setResult(next);
return next;
}),
},
});
```
Use MobX `action` or `runInAction` for local synchronous mutation when you intentionally do not need runtime policy. Prefer Frond actions when work crosses the runtime boundary.
---
Next: [Errors](/docs/authoring/errors) — failing from acquire, refresh, and actions, and how each surfaces.
---
# Errors
URL: https://frondruntime.dev/docs/authoring/errors
Description: How failures from acquire, refresh, and actions surface — readiness errors versus operation failures — plus driver timeouts, cancellation, and retries.
You signal failure by throwing (or rejecting, or `Effect.fail`). Where the failure surfaces depends on which hook threw. Failing `acquire` is a readiness error; failing `refresh` or an action is an operation failure against a node that stays ready.
| Hook fails | Result | Caller sees |
| --- | --- | --- |
| `acquire` | Node enters readiness error (`Error`). | `ensureReady()` rejects; React `useNode` throws to the error boundary. |
| `refresh` | Node stays `Ready`, result rolled back. | `operationFailure` recorded; the current result is unchanged. |
| action | Node stays `Ready`. | The action rejects; `operationFailure` recorded. |
## Failing acquire
A throw or rejection in `acquire` puts the node into a readiness error. There is no result to show.
```ts
acquire: Frond.Driver.Acquire(async (ctx) => {
const res = await fetch(`/api/profile/${ctx.args.userId}`, { signal: ctx.signal });
if (!res.ok) throw new ProfileLoadError(res.status);
return res.json();
}),
```
Outside React, `ensureReady()` rejects with the error. In React, `useNode` throws it to the nearest error boundary. The runtime does not retry on its own — retry by calling `ensureReady()` again, which starts a fresh attempt. See [Lifecycle](/docs/model/lifecycle).
## Cause chains
Frond wraps failures as they cross runtime boundaries. The outer wrapper explains what failed in Frond; the inner cause is the failure your driver or dependency produced.
For the `ProfileLoadError` above, a readiness failure can look like this when serialized:
```txt
0 FrondRuntimeReadError kind=readiness nodeId=resources/profile:v1:{"userId":"u_42"}
1 AcquireFailed nodeTag=resources/profile
2 ProfileLoadError message="Profile request failed with 500"
```
The first frame is useful to React and the runtime: it says this was a retryable readiness read for a specific node. The second frame says the `acquire` hook failed. The last frame is the domain error that should usually be visible in a report.
Dependency failures add more graph context:
```txt
0 FrondRuntimeReadError kind=readiness nodeId=resources/dashboard:v1:{}
1 DependencyFailed dependency=profile dependencyNodeId=resources/profile:v1:{"userId":"u_42"}
2 AcquireFailed nodeTag=resources/profile
3 ProfileLoadError message="Profile request failed with 500"
```
Do not flatten this too early. Product control flow needs the outer wrapper; diagnostics and reporting need the full chain. [Error projection](/docs/authoring/error-projection) is the reporting boundary that selects the meaningful root cause while preserving the Frond context.
## Failing refresh or an action
A failure in `refresh` or an action does not poison readiness. The node keeps its current result and the runtime records an operation failure.
- `refresh` rolls back to the previous result on failure.
- An action rejects to its caller (`node.actions.rename(...)` throws; `handle.runAction(...)` returns a `Failure` result). A runtime-managed `ctx.setResult(...)` commit is applied only when the action succeeds; direct MobX mutations you perform before throwing are your responsibility.
Read the last failure from `useNodeState`:
```tsx
const state = FrondReact.useNodeState(ProfileNode, { userId });
state.operationFailure; // the last operation failure, if any
```
`operationFailure.error` is the runtime failure value. Project it before showing it in UI diagnostics or sending it to a tracker:
```tsx
if (state.operationFailure) {
const report = Frond.Diagnostics.createErrorReport(state.operationFailure.error);
report.message; // presentable failure summary
}
```
This is the state-based version of React boundary projection. Readiness failures are thrown to the boundary and use `getErrorReport(error)`; action and refresh failures stay on the ready node and use `Frond.Diagnostics.createErrorReport(operationFailure.error)`.
## Timeouts
Every driver hook runs under a timeout. A hook that exceeds it fails like any other failure — an `acquire` timeout is a readiness error, a `refresh` or action timeout is an operation failure.
| Hook | Default |
| --- | --- |
| `acquire` | 15s |
| `refresh` | 15s |
| action | 15s |
| `live` | 15s |
| `release` | 5s |
Override them on the runtime:
```ts
const runtime = Frond.createRuntime({
driverTimeouts: { acquire: 30_000, action: 10_000 },
});
```
## Cancellation
When work is cancelled — a newer attempt supersedes it, or the node is released or evicted — `ctx.signal` aborts. Pass the signal to cancellable work so it stops promptly. A late result from cancelled work is not committed, so you do not need to guard against it. Cancellation is not a domain error; do not report it as one.
---
Next: [Error projection](/docs/authoring/error-projection) — why Frond unwraps runtime failures before display or reporting.
---
# Error projection
URL: https://frondruntime.dev/docs/authoring/error-projection
Description: Why Frond projects runtime errors before display or reporting — wrapper failures carry graph context, but reports should group by the real root cause.
Frond errors are cause chains. The outer error says where the runtime boundary failed: readiness, operation, dependency, live work, invalid graph, or unavailable runtime. The inner frames carry the domain failure that actually happened.
That shape is useful inside the runtime, but poor as a reporting object. If every failed action reports as `ActionFailed`, every failed refresh reports as `RefreshFailed`, or every boundary read reports as a generic runtime wrapper, your tracker groups different production failures together. The opposite problem also happens: reporting tools usually keep only a shallow `cause` chain, so the actual driver error can disappear behind Frond's wrappers.
Projection turns the full Frond failure into a reportable error.
## What projection does
`Frond.Diagnostics.projectError(error)` serializes the cause chain, skips known Frond wrapper frames, and chooses the meaningful root frame. A server 500 thrown from `acquire` should report as that server/domain failure, not merely as `AcquireFailed` or `Operation failed`.
```ts
const projection = Frond.Diagnostics.projectError(error);
projection.headline; // "Readiness failed", "Operation failed", ...
projection.rootTag; // the selected root frame
projection.rootMessage; // the root message
projection.nodeId; // graph context, when available
projection.causeChain; // serialized full chain
```
`Frond.Diagnostics.createErrorReport(error)` wraps that projection for reporting:
```ts
const report = Frond.Diagnostics.createErrorReport(error);
report.error; // Error passed to the tracker
report.fingerprint; // grouping key
report.tags; // node, kind, operation context
report.contexts; // frond summary + full cause chain
report.extra; // safe raw preview
```
The report keeps the graph context without letting the graph wrapper become the only visible error.
## Why not report the thrown error directly
The thrown value is still the correct control-flow value. React boundaries, `ensureReady()`, and action calls should receive the runtime failure as-is because it carries retry and graph context.
Reporting is a different boundary. A tracker needs:
- a stable fingerprint that separates unrelated failures
- a visible root cause
- node id, tag, dependency, and operation context
- the full cause chain as structured context, not as a best-effort nested `cause`
Projection is the handoff between those two boundaries.
## Where to use it
Use `projectError` or `createErrorReport` when a failure leaves Frond and enters UI display, logging, or a tracker.
| Surface | Use |
| --- | --- |
| Runtime sink | `Frond.Diagnostics.createRuntimeReportSink(...)` |
| One-off diagnostic | `Frond.Diagnostics.createErrorReport(error)` |
| React error boundary | `FrondReact.getErrorReport(error)` |
| Operation failure state | `Frond.Diagnostics.createErrorReport(operationFailure.error)` |
| Manual chain inspection | `Frond.Diagnostics.serializeCauseChain(error)` |
For Sentry, pass `report.error`, `report.fingerprint`, `report.tags`, `report.contexts`, and `report.extra` instead of the raw runtime wrapper. See [Sentry projection](/docs/recipes/sentry-projection).
---
Next: [Tags and diagnostics](/docs/authoring/tags-and-diagnostics) — naming nodes and what shows up in devtools and logs.
---
# Tags and diagnostics
URL: https://frondruntime.dev/docs/authoring/tags-and-diagnostics
Description: Naming nodes with Frond.tag, the labels and ids they produce, and how to read runtime diagnostics through snapshots and report sinks.
A tag names the kind of node. It is half the node's identity and the label that appears in diagnostics.
## Tags
Create a tag with `Frond.tag(value)`.
```ts
tag: Frond.tag("resources/profile"),
```
The value must be a non-empty string with no whitespace. Use stable, path-like names — `resources/profile`, `services/auth-session` — that read well grouped in a list. The tag is part of the node id, so renaming it changes the identity of every node built from the spec. See [Identity and keys](/docs/model/identity-and-keys).
## Labels and ids
A node surfaces in two forms:
| Form | Shape | Example |
| --- | --- | --- |
| Node id | `tag:key` | `resources/profile:v1:{"userId":"u_42"}` |
| Diagnostics label | `kind:tag` | `resource:resources/profile` |
The label carries the [kind](/docs/authoring/kinds), which is why the kind is worth setting even though it does not change behavior.
## Snapshots
A node snapshot is the inspectable state of one node. Each carries:
| Field | Description |
| --- | --- |
| `tag`, `kind`, `key`, `label` | Identity and naming. |
| `status` | `Unwired`, `Invalid`, or `Wired` with a run state of idle, pending, ready, or error. |
| `resultValidity` | `Current`, `Stale`, or `Expired`. |
| `operation` | The running operation, if any. |
| `operationFailure` | The last operation failure. |
| `liveDemand` | Whether the node is live, and its sources. |
Snapshots are passive — reading one never starts work or creates liveness demand.
## Report sinks
The runtime emits events for readiness, operations, validity changes, eviction, liveness, and failures. Attach a sink to receive them. `Frond.Diagnostics.createRuntimeReportSink` turns raw events into readable reports.
```ts
const runtime = Frond.createRuntime({
sinks: [
Frond.Diagnostics.createRuntimeReportSink({
name: "console",
handleReport: ({ report }) => {
console.log(report);
},
}),
],
});
```
`Frond.Diagnostics` also exposes `createErrorReport` for projecting a single failure and `serializeCauseChain` for flattening an Effect cause into readable frames. See [Error projection](/docs/authoring/error-projection).
---
Next: [React provider](/docs/react/provider) — the provider and hooks in depth.
---
# Provider
URL: https://frondruntime.dev/docs/react/provider
Description: FrondProvider puts a runtime on React context; useRuntime and useRuntimeClient read it back. One provider at the root, with optional nested overrides.
`FrondProvider` puts a runtime on React context. Every Frond hook below it reads from that runtime. Mount one at the root of your app.
```tsx
return (
);
}
```
| Prop | Type | Description |
| --- | --- | --- |
| `runtime` | `Runtime` | The runtime created with `Frond.createRuntime()`. |
| `children` | `ReactNode` | The subtree that can use Frond hooks. |
## Runtime identity
Frond does not require this, but we recommend creating the runtime outside the React tree and importing that stable instance. Holding the runtime in React state, `useMemo`, or `useRef` ties graph identity to a component lifetime. If React unmounts that provider subtree, remounts it, or recreates the memoized value, the app gets a new runtime and every node, pending acquire, live resource, and cached result in the old graph is gone.
Avoid creating the main app runtime inside the provider component:
Avoid
```tsx
function App() {
const [runtime] = useState(() => Frond.createRuntime());
return (
);
}
```
Prefer a module-level runtime for the main app graph:
Recommended
```ts
// runtime.ts
```
Use a component-owned runtime only when you intentionally want an isolated, disposable graph.
## Reaching the runtime
Two hooks read the provider:
```tsx
const runtime = FrondReact.useRuntime(); // the Runtime
const client = FrondReact.useRuntimeClient(); // runtime.client
```
`useRuntime` returns the runtime; `useRuntimeClient` returns `runtime.client` directly. Use them for imperative work — acquiring a node handle, dispatching an action outside a render path. For reading a node into a component, use [`useNode`](/docs/react/use-node) or [`useNodeState`](/docs/react/use-node-state) instead.
A hook used outside a provider throws `FrondReactUsageError`. The provider is required.
## Nested providers
The provider is a single React context. Nesting a second `FrondProvider` overrides the runtime for that subtree.
```tsx
```
Hooks inside `` read `sandboxRuntime`; everything else reads `appRuntime`. Most apps use one runtime and one provider — nesting is for isolated subtrees like a preview pane or a test harness.
---
Next: [useNode](/docs/react/use-node) — read a node with Suspense.
---
# useNode
URL: https://frondruntime.dev/docs/react/use-node
Description: useNode acquires a node and returns its ready instance, suspending until ready and throwing to the error boundary on failure. The common read path.
`useNode(spec, args)` acquires a node and returns its **ready instance**. It suspends until the node is ready and throws to the nearest error boundary if readiness fails. This is the common path: a component that needs a node's data and renders only when it has it.
```tsx
const ProfilePanel = observer(({ userId }: { userId: string }) => {
const profile = FrondReact.useNode(ProfileNode, { userId });
return
{profile.displayName}
;
});
```
The component has no loading or error branch. A Suspense boundary above handles loading; an error boundary above handles failure. See [Suspense and errors](/docs/react/suspense-and-errors).
## Wrap in observer
`useNode` returns the node instance, whose fields are MobX observables. Wrap the component in `observer` from `mobx-react-lite` when it renders node fields, getters, or result-backed data. Without `observer`, Suspense and error boundaries still work, but the component will not reliably re-render when MobX-backed fields change, and live-resource observation may not register.
## Returns
The ready node instance — the class you authored, with its getters, methods, and `actions`.
```tsx
const profile = FrondReact.useNode(ProfileNode, { userId });
profile.displayName; // a getter on the node
await profile.actions.rename({ name: "Ada" }); // a node action
```
## Args and identity
`args` determine the node id. Two `useNode(ProfileNode, { userId: "u_42" })` calls anywhere in the tree resolve to the same node — one fetch, one instance. Change the args and the hook switches to the new node, suspending if that one is not ready yet.
Inline object args are fine. The hook keys on the resolved node id, not the object reference, so `{ userId }` recreated each render does not re-acquire as long as `userId` is unchanged.
`useNode` returns only the ready instance. To also see background operations — a refresh spinner over the current value, or a stale badge — use [useNodeState](/docs/react/use-node-state), which returns the same node plus its live operation state.
---
Next: [useNodeState](/docs/react/use-node-state) — render with the lifecycle in hand.
---
# useNodeState
URL: https://frondruntime.dev/docs/react/use-node-state
Description: useNodeState returns the ready node plus operation, busy, failure, and validity state for refreshes, actions, and stale data.
`useNodeState(spec, args)` returns the ready node instance plus its live lifecycle. It has the same readiness behavior as `useNode` and adds the node's operation state. Use it to show a refresh spinner over the current value, or to mark data stale while it reloads.
```tsx
const ProfilePanel = observer(({ userId }: { userId: string }) => {
const state = FrondReact.useNodeState(ProfileNode, { userId });
return (
{state.node.displayName}
{state.busy && }
);
});
```
Wrap the component in `observer` when it renders `state.node` fields, getters, or result-backed data. You do not need `observer` for components that only read lifecycle metadata such as `state.busy`, `state.operation`, `state.operationFailure`, or `state.nodeId`; those update through the hook subscription.
## Returns
| Field | Type | Description |
| --- | --- | --- |
| `node` | node instance | The ready node. |
| `nodeId` | `NodeId` | The graph node id. |
| `operation` | `NodeOperation` | `{ _tag: "Idle" }` or `{ _tag: "Running", kind }` where `kind` is `"action"`, `"refresh"`, or `"args"`. |
| `busy` | `boolean` | `true` while an operation is running. |
| `operationFailure` | `NodeOperationFailure \| undefined` | The last operation failure, if any. |
| `resultValidity` | `DisplayableResultValidity` | `{ _tag: "Current" \| "Stale" }`. |
## Same readiness behavior as useNode
`useNodeState` suspends until the node is ready and throws readiness failures to the nearest error boundary, exactly like [useNode](/docs/react/use-node) — in fact `useNode` returns `useNodeState(...).node`. When the hook returns, `state.node` is always a ready node. See [Suspense and errors](/docs/react/suspense-and-errors).
It adds the lifecycle that happens after readiness. A background operation — a [refresh](/docs/react/use-node-controls) or an [action](/docs/authoring/actions) — runs against the already-ready node without suspending. `useNodeState` exposes it through `operation` and `busy`, which `useNode` does not return:
```tsx
{state.busy && }
{state.resultValidity._tag === "Stale" && }
```
Expired data is not background work and is not exposed as ready render data. The hook schedules readiness, suspends, and reloads. See [Result validity](/docs/model/result-validity).
## When to use which
| Hook | Returns | Use for |
| --- | --- | --- |
| [`useNode`](/docs/react/use-node) | the ready node | The common read. |
| `useNodeState` | node + operation/busy/validity | Reflecting refreshes, actions, or stale data in the UI. |
---
Next: [useNodeControls](/docs/react/use-node-controls) — refresh, evict, and release without reading the result.
---
# useNodeControls
URL: https://frondruntime.dev/docs/react/use-node-controls
Description: useNodeControls returns refresh, evict, releaseResources, and ensureReady for a node without reading its result — so the component never suspends.
`useNodeControls(spec, args)` returns the runtime operations for a node without reading its result. The component does not subscribe to the node's data, so it never suspends. Use it for a control — a refresh button, a "sign out" that evicts — that acts on a node it does not render.
```tsx
function RefreshButton({ userId }: { userId: string }) {
const controls = FrondReact.useNodeControls(ProfileNode, { userId });
return ;
}
```
No `observer` needed — `useNodeControls` reads no observable fields.
## Returns
| Member | Type | Description |
| --- | --- | --- |
| `nodeId` | `NodeId` | The graph node id. |
| `ensureReady` | `() => Promise` | Acquire and wait until ready. |
| `refresh` | `() => Promise` | Re-acquire while keeping current data visible. |
| `evict` | `(mode?, reason?) => Promise` | Remove from the graph. Default mode `"selfAndDependents"`. |
| `releaseResources` | `(reason?) => Promise` | Tear down resources, keep the wiring. |
These map to the runtime handle. See [Eviction and release](/docs/model/eviction-and-release) and [Lifecycle](/docs/model/lifecycle).
## Controls, not product behavior
`useNodeControls` covers runtime operations — refresh, evict, release. For product behavior like renaming or saving, call a node [action](/docs/authoring/actions) on the instance you read with [useNode](/docs/react/use-node) instead. Actions commit results and carry typed input and output.
## Multiple nodes
`useNodesControls` takes a map of `{ key: [Spec, args] }` and returns controls per key.
```tsx
const controls = FrondReact.useNodesControls({
profile: [ProfileNode, { userId }],
billing: [BillingNode, { userId }],
});
controls.profile.refresh();
controls.billing.evict();
```
The map's key set must be stable across renders.
---
Next: [Preload](/docs/react/preload) — acquire nodes before a subtree renders.
---
# Preload
URL: https://frondruntime.dev/docs/react/preload
Description: Preload acquires a set of nodes before its children render, suspending until they are ready. Use it at route boundaries and to order loading in layers.
`Preload` acquires a set of nodes before its children render. It suspends until they are ready, so the subtree below paints with its data already loaded. Use it at route boundaries and to consolidate Suspense above a screen that reads several nodes.
```tsx
;
```
## The nodes prop
`nodes` is an array of **layers**. Each layer is a map of `{ key: [Spec, args] }`. Within a layer, nodes load together; layers load in order — a later layer does not start until the earlier one is ready.
```tsx
;
```
Use a single layer when order does not matter. Use multiple layers to gate later acquisitions behind earlier ones.
## What it does and does not do
`Preload` suspends exactly like [useNode](/docs/react/use-node) and throws readiness failures to the error boundary. It acquires the nodes; the children still read them with their own hooks. It does not pin nodes alive on its own — once the children mount and observe, their observation is what keeps the nodes live. See [Liveness](/docs/model/liveness).
The map key set within each layer must be stable across renders.
## The hook form
`useNodes` is the hook behind `Preload`. It takes one layer map and returns the ready instances keyed the same way.
```tsx
const { profile, billing } = FrondReact.useNodes({
profile: [ProfileNode, { userId }],
billing: [BillingNode, { userId }],
});
```
Like `useNode`, it suspends until every node in the map is ready. Wrap consumers in `observer` to track their results.
---
Next: [Suspense and errors](/docs/react/suspense-and-errors) — where boundaries go and how to retry.
---
# Suspense and errors
URL: https://frondruntime.dev/docs/react/suspense-and-errors
Description: Which hooks suspend and throw, where to put Suspense and error boundaries, and how to retry a readiness failure with the Frond error helpers.
The reading hooks suspend until their node is ready and throw readiness failures to the nearest error boundary. So a screen using Frond needs two boundaries above it: a `Suspense` for loading and an error boundary for failure.
| Hook | Suspends | Throws readiness error |
| --- | --- | --- |
| [`useNode`](/docs/react/use-node) | Yes | Yes |
| [`useNodeState`](/docs/react/use-node-state) | Yes | Yes |
| [`useNodes`](/docs/react/preload) / [`Preload`](/docs/react/preload) | Yes | Yes |
| [`useNodeControls`](/docs/react/use-node-controls) | No | No |
`useNodeControls` reads no result, so it neither suspends nor throws.
## Boundaries
Put a `Suspense` and an error boundary above the suspending components. One pair can cover a whole screen — group acquisitions with [`Preload`](/docs/react/preload) so the screen has a single loading and a single error state.
```tsx
}>
;
```
## Reading the error
`getErrorReport(error)` projects any caught error into a structured, displayable report. Projection keeps Frond's graph context while surfacing the real root cause for display or reporting. See [Error projection](/docs/authoring/error-projection).
```tsx
function Fallback({ error }: { error: unknown }) {
const report = getErrorReport(error);
return
{report.headline}
;
}
```
| Field | Description |
| --- | --- |
| `headline`, `summary`, `message` | Human-readable text. |
| `kind` | The error projection kind. |
| `retryable` | Whether retrying may help. |
| `nodeId`, `nodeTag` | The node that failed, if applicable. |
| `operation`, `dependency` | Which operation or dependency failed. |
## Retrying
A readiness failure is retryable. `getErrorRecovery(error)` returns a recovery with a `retry` callback when the error is recoverable, or `undefined` otherwise. `isRecoverableNodeError(error)` is the boolean check.
```tsx
function Fallback({ error, resetErrorBoundary }: FallbackProps) {
const report = getErrorReport(error);
const recovery = getErrorRecovery(error);
return (
{report.headline}
{recovery?.retryable && (
)}
);
}
```
`recovery.retry()` starts a fresh readiness attempt; resetting the boundary re-renders the subtree, which re-reads the now-loading node and suspends again.
## Operation failures do not throw here
Only readiness failures reach the error boundary. A failed [refresh](/docs/react/use-node-controls) or [action](/docs/authoring/actions) leaves the node ready and surfaces as an operation failure, not a thrown error. Read it from [`useNodeState`](/docs/react/use-node-state)'s `operationFailure`, or handle the rejected action promise where you called it. See [Errors](/docs/authoring/errors).
---
Next: [Sentry projection](/docs/recipes/sentry-projection).
---
# Sentry projection
URL: https://frondruntime.dev/docs/recipes/sentry-projection
Description: Forward Frond runtime failures to Sentry with a report sink — the diagnostics report maps directly onto Sentry's captureException.
The runtime can stream its failures to a report sink. `Frond.Diagnostics.createRuntimeReportSink` projects each runtime failure into a `FrondErrorReport`, whose fields line up with Sentry's `captureException` scope.
Projection matters because Frond failures carry wrapper frames and cause chains. Reporting the raw thrown error often makes unrelated failures look identical — for example many different driver failures can appear as the same operation or readiness wrapper. It can also hide the actual root error behind a shallow `cause` chain. See [Error projection](/docs/authoring/error-projection) for the model.
## The sink
```ts
sinks: [
Frond.Diagnostics.createRuntimeReportSink({
name: "sentry",
handleReport: ({ report }) => {
Sentry.captureException(report.error, {
fingerprint: [...report.fingerprint],
tags: report.tags,
contexts: report.contexts,
extra: report.extra,
});
},
}),
],
});
```
`handleReport` runs once per failure in a runtime event. The `report` is a `FrondErrorReport`:
| Field | Maps to |
| --- | --- |
| `error` | The `Error` passed to `captureException`. |
| `message` | A readable summary. |
| `fingerprint` | Sentry grouping fingerprint. |
| `tags` | Sentry tags (node id, tag, kind). |
| `contexts`, `extra` | Sentry contexts and extra data. |
The sink fires only on failures — readiness errors, operation failures, dependency failures. Healthy events do not call it.
## Failures in React
The sink covers failures anywhere in the runtime. For a failure that surfaced to a React error boundary, `getErrorReport(error)` gives you the same shape to render in the fallback. See [Suspense and errors](/docs/react/suspense-and-errors).
```tsx
function Fallback({ error }: { error: unknown }) {
const report = getErrorReport(error);
return
{report.headline}
;
}
```
If that boundary also reports to Sentry, capture `report.diagnostic.error` with `report.diagnostic.fingerprint`, `tags`, `contexts`, and `extra`. Do not capture the raw boundary error directly; that value is the React control-flow wrapper, not the tracker projection.
## Operation failures in UI state
Action and refresh failures do not throw to a React error boundary. They leave the node ready and appear as `operationFailure` on `useNodeState`. Project `operationFailure.error` before reporting it manually.
```tsx
function ProfileStatus({ userId }: { userId: string }) {
const state = useNodeState(ProfileNode, { userId });
function reportLastOperationFailure() {
if (!state.operationFailure) return;
const report = Frond.Diagnostics.createErrorReport(state.operationFailure.error);
Sentry.captureException(report.error, {
fingerprint: [...report.fingerprint],
tags: report.tags,
contexts: report.contexts,
extra: report.extra,
});
}
return (
);
}
```
Use the same projection rule for both surfaces: boundary errors use `getErrorReport(error)`, stored operation failures use `Frond.Diagnostics.createErrorReport(operationFailure.error)`.
## Why a sink, not a try/catch
Failures come from many paths — an `acquire` rejection, a dependency that never readied, an action timeout. Routing them through the runtime sink means every failure reports the same way, with the node id and tag already attached, without wrapping each call site. See [Tags and diagnostics](/docs/authoring/tags-and-diagnostics).
---
Next: [Pagination](/docs/recipes/pagination).
---
# Pagination
URL: https://frondruntime.dev/docs/recipes/pagination
Description: Model each page as its own node keyed by cursor. Pages cache by identity, so revisiting a page is instant and prefetching the next is one acquire.
Make the cursor part of the args, and each page becomes its own node with its own id. Pages cache by identity: revisiting a page reads the existing node, and prefetching the next is a single acquire.
## One node per page
```ts
type FeedPageResult = {
items: FeedItem[];
nextCursor: string | null;
};
type FeedPageSpec = Frond.NodeSpec<{
args: { cursor: string | null };
key: Frond.Key.Structure<{ cursor: string | null }>;
result: FeedPageResult;
}>;
static readonly spec = Frond.resourceSpec({
tag: Frond.tag("resources/feed-page"),
key: (args) => Frond.Key.structure({ cursor: args.cursor }),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) => fetchFeedPage(ctx.args.cursor)),
}),
});
}
```
The cursor is in the key, so `{ cursor: null }` and `{ cursor: "abc" }` are different nodes. Each loads once and stays cached until evicted. See [Identity and keys](/docs/model/identity-and-keys).
## Render the current page
Hold the current cursor in component state and read its node.
```tsx
const FeedPage = observer(({ cursor }: { cursor: string | null }) => {
const page = FrondReact.useNode(FeedPageNode, { cursor });
return (
<>
>
);
});
```
Moving back to a page you already loaded resolves to the cached node — no refetch.
## Prefetch the next page
Acquire the next page before the user asks for it. Outside React, acquire through the client; the node is ready by the time you navigate.
```ts
function prefetch(cursor: string) {
void runtime.client.node(FeedPageNode, { cursor }).ensureReady();
}
```
In React, [Preload](/docs/react/preload) acquires a page before rendering it, and [useNodeControls](/docs/react/use-node-controls) can `evict` pages you no longer want cached.
---
Next: [Auth-aware nodes](/docs/recipes/auth-aware-nodes).
---
# Auth-aware nodes
URL: https://frondruntime.dev/docs/recipes/auth-aware-nodes
Description: Make the session a dependency of user-scoped nodes, then evict the session on logout to tear down everything that depended on it.
Model the session as a service node, and make every user-scoped node depend on it. The session gates readiness — nothing user-scoped readies without it — and evicting it on logout cascades to the whole user subgraph.
## The session node
```ts
type SessionResult = { userId: string; token: string };
type SessionSpec = Frond.NodeSpec<{
args: Frond.Args.None;
key: Frond.Key.Singleton;
result: SessionResult;
actions: {
signOut: Frond.ActionContract;
};
}>;
static readonly spec = Frond.serviceSpec({
tag: Frond.tag("services/session"),
key: () => Frond.Key.singleton(),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire(() => loadSession()),
actions: {
signOut: Frond.Driver.Action(() => clearSession()),
},
}),
});
}
```
## Depend on it
A user-scoped node declares the session as a dependency and reads it through `ctx.deps`.
```ts
dependencies: Frond.dependencies(() => ({
session: Frond.dep(SessionNode, Frond.Args.none),
})),
driver: Frond.Driver.Async({
acquire: Frond.Driver.Acquire((ctx) =>
fetchProfile(ctx.deps.session.result.userId)
),
}),
```
The profile cannot become ready until the session is ready, and it reads the user id straight from the session node — no prop drilling, no duplicated auth state. See [Dependencies](/docs/model/dependencies).
## Tear down on logout
Evicting the session evicts its reverse dependency closure: every user-scoped node that depends on it, directly or transitively.
```ts
await runtime.client.node(SessionNode, Frond.Args.none).evict();
// session + profile + every other user-scoped node are removed
```
The next render requests fresh nodes against a new session. One eviction clears the user's entire graph — no manual cache invalidation per resource. See [Eviction and release](/docs/model/eviction-and-release).
---
Next: [Effect escape hatches](/docs/recipes/effect-escape-hatches).
---
# Effect escape hatches
URL: https://frondruntime.dev/docs/recipes/effect-escape-hatches
Description: Async drivers cover most nodes. Reach for Frond.Driver.Effect when your code is already in Effect or you want Effect's concurrency, retries, and interruption.
`Frond.Driver.Async` is the default and covers most nodes — return a value or a `Promise` and you are done. `Frond.Driver.Effect` is the escape hatch: the same hooks, written in Effect. Reach for it when your code already lives in Effect, or when you want Effect's structured concurrency, retries, and scheduling inside a hook.
## The same driver, in Effect
```ts
driver: Frond.Driver.Effect({
acquire: Frond.Driver.Acquire((ctx) =>
Effect.gen(function* () {
const profile = yield* ctx.tryPromise((signal) =>
fetch(`/api/profile/${ctx.args.userId}`, { signal }).then((r) => r.json())
);
return profile;
})
),
}),
```
The hooks are identical to the async driver — `acquire`, `refresh`, `release`, `live`, `actions` — but each returns an `Effect`. On the Effect context, `setResult`, `setResultValidity`, and `patchResult` return Effects you `yield*`.
## Bridging promises
`ctx.tryPromise((signal) => run(signal))` lifts a promise into Effect and wires the abort signal for you, so cancellation propagates without extra plumbing.
```ts
yield* ctx.tryPromise((signal) => fetchWithRetry(url, { signal }));
```
In an async driver you would pass `ctx.signal` to `fetch` directly; `tryPromise` is the Effect equivalent.
## When to reach for it
| Use Async | Use Effect |
| --- | --- |
| Plain `async`/`await` or promises. | Code already written in Effect. |
| A single fetch or call. | Combining effects — concurrency, racing, retry schedules. |
| The common case. | You want Effect's interruption and resource scoping inside the hook. |
Effect drivers can require Effect services through the hook's environment. Note that this applies to the driver only — a node's `key` function must stay pure and may not use Effect services, time, or randomness. See [Identity and keys](/docs/model/identity-and-keys).
Whichever you choose, the runtime treats the normalized driver identically: readiness, serialization, result validity, timeouts, cancellation, and cleanup do not change. The driver mode is an authoring choice, not a behavior change. See [Drivers](/docs/authoring/drivers).
---
Next: [Public surface](/docs/reference/public-surface) — the full export map.
---
# Public surface
URL: https://frondruntime.dev/docs/reference/public-surface
Description: The exports of @frondruntime/core and @frondruntime/react, plus their /testing subpaths, grouped by purpose.
Two packages, imported as namespaces.
```ts
```
## @frondruntime/core
### Runtime
| Export | Purpose |
| --- | --- |
| `Frond.createRuntime(options?)` | Create a runtime. See [Runtime and graph](/docs/model/runtime-and-graph). |
| `Frond.createRuntimeClient(runtime)` | Build a client over a runtime host. |
| `Frond.Runtime` | Runtime types — `Runtime`, `RuntimeClient`, `RuntimeNodeHandle`, reads, snapshots, events. |
### Node authoring
| Export | Purpose |
| --- | --- |
| `Frond.NodeBase` | Base class every node extends. |
| `Frond.resourceSpec` / `serviceSpec` / `facadeSpec` / `nodeSpec` | Spec constructors. See [Kinds](/docs/authoring/kinds). |
| `Frond.tag(value)` | Create a node tag. |
| `Frond.dep(spec, args)` | Declare one dependency. |
| `Frond.dependencies(resolver)` | Declare a node's dependency set. |
| `Frond.Args` | `Args.None` / `Args.none` for no-arg nodes. |
| `Frond.resultCommit(result, options)` | Attach validity/load metadata to a result. |
| `Frond.NodeSpec`, `Frond.NodeTag`, `Frond.Dep`, … | Authoring types. |
### Namespaces
| Namespace | Contents |
| --- | --- |
| `Frond.Driver` | `Async`, `Effect`, `Acquire`, `Refresh`, `Release`, `Action`, `Live`. See [Drivers](/docs/authoring/drivers). |
| `Frond.Key` | `singleton()`, `structure()`, `canonicalKey()`, `Singleton`, `MAX_CANONICAL_KEY_LENGTH`, key errors. |
| `Frond.Signals` | `channel()`, signal construction, runtime signal types. |
| `Frond.Diagnostics` | `createRuntimeReportSink`, `createErrorReport`, `projectError`, `serializeCauseChain`. See [Error projection](/docs/authoring/error-projection) and [Tags and diagnostics](/docs/authoring/tags-and-diagnostics). |
| `Frond.Graph` | Graph types — `NodeId`, reads, snapshots, `EvictResult`, `ResultValidity`, failures. |
| `Frond.Events` | Runtime event types. |
| `Frond.MobX` | MobX interop helpers. |
## @frondruntime/react
| Export | Purpose |
| --- | --- |
| `FrondReact.FrondProvider` | Put a runtime on context. See [Provider](/docs/react/provider). |
| `FrondReact.useRuntime` / `useRuntimeClient` | Reach the runtime imperatively. |
| `FrondReact.useNode` | Acquire and read with Suspense. See [useNode](/docs/react/use-node). |
| `FrondReact.useNodeState` | Read with the lifecycle. See [useNodeState](/docs/react/use-node-state). |
| `FrondReact.useNodes` | Read a map of nodes. |
| `FrondReact.useNodeControls` / `useNodesControls` | Refresh, evict, release. See [useNodeControls](/docs/react/use-node-controls). |
| `FrondReact.Preload` | Acquire before render. See [Preload](/docs/react/preload). |
| `FrondReact.getErrorReport` | Project a caught error into a displayable report. |
| `FrondReact.getErrorRecovery` / `isRecoverableNodeError` | Inspect and retry a readiness failure. See [Suspense and errors](/docs/react/suspense-and-errors). |
## Testing subpaths
```ts
```
See [Testing](/docs/reference/testing) for the harness, runtime helper, and spec stubs.
---
Next: [Glossary](/docs/reference/glossary).
---
# Glossary
URL: https://frondruntime.dev/docs/reference/glossary
Description: The terms used across the Frond docs, with their code symbols and where each is covered in full.
| Term | Meaning |
| --- | --- |
| **Runtime** | The single object that owns the graph. `Frond.createRuntime()`. See [Runtime and graph](/docs/model/runtime-and-graph). |
| **Graph** | The set of live nodes and their dependency edges. |
| **Node** | One slot in the graph: an identity, a result, and a lifecycle. A class extending `Frond.NodeBase`. See [Spec and class](/docs/authoring/spec-and-class). |
| **Node spec** | The static configuration on a node — tag, key, dependencies, driver. `static readonly spec`. |
| **Kind** | The spec category (resource, service, facade, or node), labeling intent. See [Kinds](/docs/authoring/kinds). |
| **Tag** | A stable, path-like string naming the kind of node. `Frond.tag(value)`. |
| **Args** | The typed input passed when requesting a node. `Frond.Args.None` for none. |
| **Key** | The pure, deterministic identity value from `key(args)`. See [Identity and keys](/docs/model/identity-and-keys). |
| **Graph node id** | `tag:key` — the identity a node resolves to. |
| **Dependency** | An edge to another node, declared with `Frond.dep`. See [Dependencies](/docs/model/dependencies). |
| **Driver** | The side-effect implementation behind a node. `Frond.Driver.Async` / `Effect`. See [Drivers](/docs/authoring/drivers). |
| **Acquire** | The driver hook that produces the initial result. |
| **Refresh** | A background re-load of a ready node's result. |
| **Action** | A runtime-mediated method on a node. `Frond.ActionContract`, `Frond.Driver.Action`. See [Actions](/docs/authoring/actions). |
| **Result** | The value a ready node holds. `this.result`. |
| **Result validity** | Whether the result may be shown — `Current`, `Stale`, `Expired`. See [Result validity](/docs/model/result-validity). |
| **Readiness** | Whether a node has loaded data. Crossed with `ensureReady()`. |
| **Operation** | Background work on a ready node — action, refresh, or args reconciliation. See [Lifecycle](/docs/model/lifecycle). |
| **Handle** | A reference to a node by id from `runtime.client.node(...)`. |
| **Release** | Cleaning a node's resources while keeping its graph wiring. |
| **Eviction** | Removing a node and its dependents from the graph. See [Eviction and release](/docs/model/eviction-and-release). |
| **Liveness** | Demand for a node's driver live work. See [Liveness](/docs/model/liveness). |
| **Live lease** | A recorded demand for live work — `mobx` or `manual`. |
| **Snapshot** | The passive, inspectable state of a node or the graph. |
| **Signal** | A runtime message on a channel. `Frond.Signals`. |
| **Sink** | A consumer of runtime events. See [Tags and diagnostics](/docs/authoring/tags-and-diagnostics). |
---
Next: [Runtime invariants](/docs/reference/runtime-invariants).
---
# Runtime invariants
URL: https://frondruntime.dev/docs/reference/runtime-invariants
Description: The guarantees the runtime upholds for every node, no matter which caller drives it.
These are the properties the runtime holds for every node, whether the caller is React, the client, a test, or a script.
| Invariant | What it means |
| --- | --- |
| **Deterministic identity** | A node's id is `tag:key`, where `key` is pure and deterministic. The same tag and args always resolve to the same node. See [Identity and keys](/docs/model/identity-and-keys). |
| **Static dependency topology** | A node's dependency set is fixed once wired. Args reconciliation cannot change which nodes it depends on. See [Dependencies](/docs/model/dependencies). |
| **Dependency-first readiness** | A node cannot be ready until every node it depends on is ready. |
| **Wiring failure ≠ driver failure** | A planning failure (bad key, cycle, duplicate tag) makes the node invalid; it is not a driver/readiness failure. |
| **Readiness failure is retryable** | A failed `acquire` leaves the node retryable — calling `ensureReady()` again starts a fresh attempt. See [Errors](/docs/authoring/errors). |
| **Stable pending attempt** | Concurrent callers awaiting readiness join the same in-flight attempt; they do not each start their own. |
| **Per-node serialized work** | Driver work for one node id runs serialized. A stale request cannot commit over a fresh one; two actions cannot interleave. |
| **Refresh ≠ retry** | Refresh updates a ready node in the background and rolls back on failure; it is distinct from retrying a readiness error. |
| **Operation failure ≠ readiness failure** | A failed action or refresh leaves the node ready with an operation failure; it does not poison readiness. See [Lifecycle](/docs/model/lifecycle). |
| **Expired data is hidden** | An expired result is not exposed to ordinary reads; active consumers reload it through readiness. See [Result validity](/docs/model/result-validity). |
| **Release ≠ eviction** | Release clears resources but keeps identity and edges; eviction removes the graph record and edges. See [Eviction and release](/docs/model/eviction-and-release). |
| **Liveness ≠ React presence** | Driver live work is driven by observation and manual leases, not by a component mounting. See [Liveness](/docs/model/liveness). |
| **Owned cleanup** | Node and driver cleanup runs through disposer bags and the driver `release` hook, on one graph-owned path. |
| **Caller-independent** | The same contract holds for every caller; it does not depend on the UI disabling a control. |
Underneath all of these: every lifecycle fact has exactly one owner, the runtime.
---
Next: [Testing](/docs/reference/testing).
---
# Testing
URL: https://frondruntime.dev/docs/reference/testing
Description: The Frond test harness, stub specs, and the React test provider — drive nodes, wait for readiness, and swap dependencies for fixed values.
Frond ships testing helpers in two subpaths: `@frondruntime/core/testing` for a headless harness, and `@frondruntime/react/testing` for a provider that wires one into a render.
```ts
```
## The harness
`createFrondTestHarness(options)` builds a runtime, captures its events, and adds helpers for driving and reading nodes.
```ts
const harness = FrondTest.createFrondTestHarness();
await harness.start();
const profile = await harness.startNode(ProfileNode, { userId: "u_42" });
expect(profile.displayName).toBe("Ada");
await harness.teardown();
```
| Member | Purpose |
| --- | --- |
| `start()` / `stop()` / `teardown()` | Lifecycle. `teardown` is idempotent. |
| `startNode(spec, args)` | Acquire and return the ready node instance. |
| `startNodes(map)` | Acquire a keyed map of nodes. |
| `node(spec, args)` | Get a handle without acquiring. |
| `readReady(handle)` / `readError(handle)` | Read the current ready or error state. |
| `waitForEvent(predicate)` | Resolve when a matching runtime event fires. |
| `waitForNodeRead(handle, predicate)` | Resolve when a node's read matches the predicate. |
| `waitForIdle()` | Resolve when no work is in flight. |
| `runtime`, `client`, `events` | The underlying runtime, client, and captured event records. |
For a runtime without the extra helpers, `createTestRuntime(options)` returns `{ runtime, client, sink, events }`.
## Stubbing dependencies
`readySpec` replaces a node with one that is instantly ready with a fixed result — no driver, no dependencies. `mockSpec` overrides a node's dependencies or driver. Apply either with `specOverrides`, a list of `{ from, to }`.
```ts
const ReadyDependency = FrondTest.readySpec(SessionNode, { userId: "u_42", token: "t" });
const ProfileWithStubSession = FrondTest.mockSpec(ProfileNode, {
dependencies: Frond.dependencies(() => ({
session: Frond.dep(ReadyDependency, Frond.Args.none),
})),
});
const harness = FrondTest.createFrondTestHarness({
specOverrides: [{ from: ProfileNode, to: ProfileWithStubSession }],
});
```
Requests for `ProfileNode` now resolve to the mocked spec, which depends on the ready stub instead of the real session. Tags and identity are preserved, so the rest of the graph is unaffected.
## Controlling timing
`createDeferredDriver` gives a driver whose `acquire` and operations you resolve by hand — useful for asserting pending states before data arrives.
```ts
const driver = FrondTest.createDeferredDriver({ release: true });
// resolve the pending acquire when the test is ready
```
## React
`TestFrondProvider` wires a harness into a render tree. Pass a `runtime`, an existing `harness`, or `options` to have it create and tear down its own.
```tsx
render(
);
```
When it owns the harness, it tears it down on unmount.
---
Back to the [Model](/docs/model/runtime-and-graph) for the concepts, or [Authoring](/docs/authoring/spec-and-class) to build a node.