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

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:

  1. A spec typeFrond.NodeSpec<{ args, key, deps, result, actions }> — that names everything the runtime needs at the type level.
  2. A classextends Frond.NodeBase<Spec> — 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.

// 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:

  • 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:

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" }:

  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).

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.