Your first node
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:
- A spec type —
Frond.NodeSpec<{ args, key, deps, result, actions }>— that names everything the runtime needs at the type level. - A class —
extends Frond.NodeBase<Spec>— that carries onestatic readonly specand any domain methods you add on top ofthis.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.
// src/nodes/profile.ts
import * as Frond from "@frondruntime/core";
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;
}>;
export class ProfileNode extends Frond.NodeBase<ProfileSpec> {
static readonly spec = Frond.resourceSpec<ProfileSpec>({
tag: Frond.tag("app/profile"),
key: (args) => Frond.Key.structure({ userId: args.userId }),
driver: Frond.Driver.Async<ProfileSpec>({
acquire: Frond.Driver.Acquire(async (ctx): Promise<Profile> => {
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:
tagis the human-readable label — what shows up in devtools, error reports, logs. Keep it stable across versions.keyis 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).acquirereceives actxwith yourargs, the resolveddeps(none here), and anAbortSignaltied to the node’s lifecycle. Pass the signal tofetchand the request cancels automatically when the node is released.releaseruns once when the node leaves the graph. Empty here, but this is where sockets close, timers clear, subscriptions tear down.displayNameis a domain method on your node class. The runtime gives youthis.resultonce ready; you build whatever surface your callers want on top of it.
Read the node
Outside React you go through the runtime client directly:
import { runtime } from "./runtime";
import { ProfileNode } from "./nodes/profile";
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" }:
- The runtime computed the identity key from your args.
- It looked for an existing node with that key. None existed, so it created an idle slot.
ensureReady()triggeredacquire. The runtime called your function with the lifecycle-scopedctx, awaited the promise, and stored the result.- It constructed
ProfileNode, setthis.result, and transitioned the slot toReady. 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).
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.