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:
| Step | Purpose |
|---|---|
$atom = expr | Write to reactive state. += -= *= /= ??= ++ -- are also allowed. |
name = expr | Local 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 / for | Statement-form control flow (same keywords as the expression form). |
js{ … } | JavaScript escape hatch with access to ctx (state, tools, host). |
return expr | Optional 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")
)
Navigation
_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])