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

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<Input, Output>.

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<void, ProfileResult>;
  };
}>;

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.

driver: Frond.Driver.Async<ProfileSpec>({
  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.

const profile = FrondReact.useNode(ProfileNode, { userId });
await profile.actions.rename({ name: "Ada" }); // Promise<ProfileResult>

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.

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.

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
export class ProfileNode extends Frond.NodeBase<ProfileSpec> {
  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:

driver: Frond.Driver.Async<ProfileSpec>({
  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 — failing from acquire, refresh, and actions, and how each surfaces.