Behaviour

Actions: wire every visible button.

Actions are imperative blocks invoked by events — a button click, a form submit, a follow-up tap. They’re the only place a script mutates state, calls HTTP, navigates, or emits to the host.

Hello, action

Declare an action at the top level; reference it from an event-handler prop:

$count = 0

_app_ = Card([
  CardHeader("Counter", subtitle: "Reactive state via action"),
  Text(`Count: ${$count}`),
  Button("Increment", action: increment)
])

action increment() {
  $count = $count + 1
}

That’s the entire model: name a block, reference the name. When the button is clicked, the runtime invokes the block, applies every state write atomically, then re-renders.

Signatures & invocations

Positional arguments

action addItem(title, price) {
  $items = [...$items, { id: $items.length + 1, title: title, price: price }]
}

# Invoke via a lambda that forwards the arguments
Button("Add coffee", action: () => addItem("Coffee", 3.5))

Per-row handlers

rows = for task in $tasks {
  Stack([
    Text(task.title),
    Button("Toggle", action: () => toggleDone(task.id)),
    Button("Delete", action: () => deleteTask(task.id))
  ])
}

Inline lambdas

For one-shot handlers, skip the named declaration:

Button("Reset", action: () => {
  $count = 0
  $error = null
})

Button("+", action: () => $count = $count + 1)

Return values

Actions MAY include a return statement. The result is observable from $x = myAction(...) expressions:

action greet(name) {
  return "Hello, " + name
}

$hello = greet("Ada")             # re-runs whenever the action call's args change

Body grammar

Inside an action body the imperative surface is small:

StepPurpose
$atom = exprWrite to reactive state. += -= *= /= ??= ++ -- are also allowed.
name = exprLocal binding for use inside this action block (transient, not reactive).
$bag = http({ … })Fire an HTTP request. Result is reactive: .data, .loading, .error, .status, .refetch(), .cancel().
_route_.navigate("/path")Router transition. Updates the URL hash, fires route-change.
emit "event-name" { … }Dispatch a CustomEvent on the host element.
storage.set("key", value)Read or write localStorage / sessionStorage / cookies (see globals).
console.log("...")Log to the browser console.
otherAction(args)Invoke another action declared elsewhere in the program.
if / match / forStatement-form control flow (same keywords as the expression form).
js{ … }JavaScript escape hatch with access to ctx (state, tools, host).
return exprOptional return value.

State writes — immutable patterns

Nested writes require whole-object replacement — direct mutation of object fields ($user.name = "Alex") is rejected. Use spread and built-in filters instead:

action toggleDone(taskId) {
  $tasks = for task in $tasks {
    if task.id == taskId { { ...task, done: !task.done } } else { task }
  }
}

action removeTask(taskId) {
  $tasks = @Filter($tasks, "id", "!=", taskId)
}

action updateUserName(newName) {
  $user = { ...$user, name: newName }
}

action appendTodo(item) {
  $todos = [...$todos, item]
}

Multiple writes inside one action are batched: the renderer only fires once, no matter how many atoms change.

HTTP from actions

Mutations are just http({...}) calls with a non-GET method. Bind the resource bag to an atom so the UI can show loading and error states:

action saveTask() {
  $save = http({
    url:    "/api/tasks",
    method: "POST",
    body:   { title: $newTaskTitle, done: false }
  })
  $newTaskTitle = ""
}

# Show progress / error state in the UI
status = Async($save,
  loading: LoadingState("Saving…"),
  error:   ErrorState("Couldn't save", description: $save.error),
  data:    Callout("success", title: "Saved")
)

_route_ is the reactive router handle. Call its navigate(path) method to transition the URL:

action openOrder(id) {
  _route_.navigate("/orders/" + id)
}

For more on routing — matching, params, queries — see the Routing guide.

Emit custom events

Use emit to dispatch a CustomEvent on the host element. The host listens with addEventListener.

action submit() {
  emit "form-submitted" { values: { email: $email, name: $name } }
}

// Host:
el.addEventListener("form-submitted", (event) => {
  console.log(event.detail.values);
});

Follow-up clicks — assistant-message

Several chat-oriented helpers (FollowUpBlock, FollowUpItem, ActionLink, and any Button whose action calls helpers.sendToAssistant("...") in custom components) dispatch an assistant-message event with the user's intent. Wire it into your chat composer:

_app_ = Stack([
  Markdown("Want to dig deeper? Try one of these:"),
  FollowUpBlock([
    "Show daily breakdown",
    "Compare with last week",
    "Export CSV"
  ])
])

// Host:
el.addEventListener("assistant-message", (event) => {
  sendToLLM(event.detail.message);
});

JavaScript escape hatch

For browser APIs the declarative surface doesn’t cover, embed a js{ … } block inside the action body. The block runs as an async function and has access to a ctx bridge: state reads/writes, host-registered tools, the host element, cleanup registration, and the action's arguments.

action copyShareLink() {
  js {
    await navigator.clipboard.writeText(window.location.href)
    ctx.state.set("copied", true)
  }
}

See the JavaScript interactions guide for the full ctx surface.

Talking to the host — tools

Host-registered tools are async functions exposed to js{} blocks as ctx.tools.<name>(args). The host registers them with el.setTools({...}):

// Host
el.setTools({
  uploadFile: async ({ file }) => {
    const formData = new FormData();
    formData.append("file", file);
    const res = await fetch("/api/upload", { method: "POST", body: formData });
    return res.json();
  }
});

# Script
action upload(file) {
  js {
    const result = await ctx.tools.uploadFile({ file: ctx.args.file })
    ctx.state.set("uploadUrl", result.url)
  }
}

Common patterns

Form submit + reset

$name = ""
$email = ""
$message = ""

action submit() {
  if !$name || !$email { return }
  $post = http({
    url:    "/api/contact",
    method: "POST",
    body:   { name: $name, email: $email, message: $message }
  })
}

action resetForm() {
  $name = ""
  $email = ""
  $message = ""
}

btns = Buttons([
  Button("Send",  action: submit,    variant: "primary", icon: "paper-plane"),
  Button("Reset", action: resetForm, variant: "ghost")
])

_app_ = Form("contact", buttons: btns, fields: [
  FormControl("Name",    control: Input("name",    placeholder: "Your name",       value: $name)),
  FormControl("Email",   control: Input("email",   placeholder: "you@example.com", type: "email", value: $email)),
  FormControl("Message", control: TextArea("message", rows: 4, value: $message))
])

Per-row actions in a table

rows = for task in $tasks {
  Stack([
    Badge(task.done ? "done" : "open", tone: task.done ? "success" : "neutral"),
    Text(task.title),
    Buttons([
      Button(task.done ? "Reopen" : "Done", action: () => toggleDone(task.id), variant: "primary", size: "sm"),
      Button("Delete",                       action: () => deleteTask(task.id), variant: "ghost",   size: "sm")
    ])
  ])
}

action toggleDone(id) {
  $tasks = for t in $tasks {
    if t.id == id { { ...t, done: !t.done } } else { t }
  }
}

action deleteTask(id) {
  $tasks = @Filter($tasks, "id", "!=", id)
  $remove = http({ url: "/api/tasks/" + id, method: "DELETE" })
}

Confirm before destructive action

$pendingDelete = null

action requestDelete(id) { $pendingDelete = id }
action cancelDelete()    { $pendingDelete = null }
action confirmDelete() {
  $tasks = @Filter($tasks, "id", "!=", $pendingDelete)
  $pendingDelete = null
}

dialog = Modal("Delete this task?",
  open: $pendingDelete != null,
  footer: [
    Button("Cancel", action: cancelDelete, variant: "ghost"),
    Button("Delete", action: confirmDelete, variant: "danger")
  ],
  children: [Text("This cannot be undone.")]
)

_app_ = Stack([TaskTable(rows: $tasks, onDelete: requestDelete), dialog])

Next