Actions.
Actions are function declarations that run when
the user does something — clicks a button, types in a
field, submits a form. They are the imperative counterpart to reactive
bindings: where a binding describes a value, an action describes what
happens next.
Anatomy
An action is an ordinary function declaration:
function name(args) { … }. The body is imperative
JavaScript — read and write state, call $http,
navigate, emit an event — and it runs to completion before the
runtime paints.
The first letter of the name decides how the declaration is
registered, not where you may use it. A PascalCase
function Panel() is registered as both a
component and an action, so Panel() renders in a render
position and onClick: Panel runs the body.
A lowercase function save() is registered as an action only
— but a call in render position renders whatever the body returns, so
a lowercase helper that returns a node still renders. Either spelling works
in either position.
| Wire it to… | Prop |
|---|---|
| A button press | Button("Save", { onClick: save }) |
| Typing / value changes | Input("q", { onChange: (v) => $q = v }) |
| A form submit | Form({ onSubmit: submit, fields: […] }) — see Wiring a form submit |
When the event fires the runtime runs the function, applies its state
writes as they execute, then batches them into a single
re-render. Pass the name (onClick: save) when the
handler takes no arguments, or a thin lambda
(onClick: () => remove(id)) when you need to forward
something.
The demo below is one program using both directions at once:
summary() is lowercase and renders,
Cycle is PascalCase and is handed to onClick
by name. Nothing about the call sites had to change to match the casing.
$tone = "info"
$runs = 0
function summary() {
return Callout("A lowercase declaration renders here", {
tone: $tone,
description: `Cycle has run ${$runs} time(s); it was passed to onClick by name.`,
compact: true,
actions: [Button("Reset", { onClick: reset, variant: "ghost", size: "sm" })]
})
}
function Cycle() {
$runs = $runs + 1
$tone = $tone == "info" ? "success" : "info"
}
function reset() {
$runs = 0
$tone = "info"
}
$app(Column([
summary(),
Button("Run Cycle", { onClick: Cycle, variant: "primary" })
], { gap: "md" }))
Callout’s first argument is positional and is the
title — Callout(title, { tone, description,
actions, … }). The actions prop takes a list of
nodes rendered under the body, which is where a “Retry” or
“Reset” button belongs.
Wiring a form submit
Form({ onSubmit, fields, buttons }) takes its inputs as a
fields array of FormControl nodes and its
submit controls as buttons. onSubmit fires when
the user presses Enter in a focused input or clicks a
type: "submit" button inside the form — you never wire
onClick on the submit button yourself.
$email = ""
$password = ""
$signedIn = false
function signIn() {
$signedIn = true
}
$app($signedIn
? Callout("Signed in", { tone: "success", description: $email })
: Form({
onSubmit: signIn,
fields: [
FormControl({ label: "Email", field: Input({ type: "email", value: $email, onChange: (v) => $email = v }) }),
FormControl({ label: "Password", field: Input({ type: "password", value: $password, onChange: (v) => $password = v }) })
],
buttons: Button("Sign in", { type: "submit", variant: "primary" })
}))
Form also takes error for a form‑level
message and loading: true while a submit is in flight
— that marks the form aria-busy, disables its submit
buttons, and blocks re‑entry. For validation rules, dirty/touched
tracking, and async validators, reach for the
$form engine instead of wiring
each field by hand.
Reading & writing state
The only place a script mutates state is inside an action (or an effect /
lambda body). Writing $name = value is reactive — every
binding that reads $name re-renders.
The compound assignment operators += -= *= /= ??= and
++ -- all work, and member writes like
$user.name = "Alex" rebuild the root object immutably so
subscribers see a fresh reference.
$count = 0
function inc() { $count = $count + 1 }
function dec() { $count -= 1 }
$app(Card([Column([
Text(`Count: ${$count}`, { variant: "large-heavy" }),
Row([
Button("−", { onClick: dec, variant: "ghost" }),
Button("Increment", { onClick: inc, variant: "primary" })
], { gap: "sm" })
], { gap: "md" })]))
Both buttons re-render the same Text, because both write the
same atom — you never tell the runtime what to update, only
what the new value is.
Event handlers: inline vs named
For a one-liner, an inline lambda right on the prop is the cleanest thing:
onClick: () => $open = true. Reach for a named
function when the body grows past a statement or two, when you
want to reuse it across several controls, or when it simply reads better
with a name.
$open = false
function close() { $open = false }
$app(Column([
Button("Open", { onClick: () => $open = true, variant: "primary" }),
$open ? Callout("Now open", { tone: "info", description: "Inline lambda opened me; a named action closes me." }) : Text("Closed."),
$open ? Button("Close", { onClick: close, variant: "ghost" }) : Spacer()
], { gap: "md" }))
The two forms are interchangeable at the call site — a lambda and a named action both arrive at the prop as a callable, so switching one for the other later is a rename, not a refactor.
Passing arguments
When a handler needs to know which item it is acting on, wrap the
call in a lambda that forwards the id. This is the standard shape for
per-row buttons built with .map:
$items = [{ id: 1, name: "Apples" }, { id: 2, name: "Pears" }, { id: 3, name: "Plums" }]
function remove(id) { $items = $items.filter(x => x.id != id) }
$app(Card([Column($items.map(item => Row([
Text(item.name),
Spacer(),
Button("Remove", { onClick: () => remove(item.id), variant: "ghost", size: "sm" })
], { gap: "sm" })), { gap: "sm" })]))
Talking to the server
A write to the server is just an $http({...}) call with a
non-GET method, fired from inside an action. $http(...) returns
a reactive resource — bind it to an atom and you get
.data, .error, .loading,
.refetch(), and .cancel().
The clean way to refresh a list after a write is the settable
.onDone callback. Assign it after creating the resource; it
fires once every time the request settles — on the initial
load and every refetch(), on both success and error. It does
not fire for a request that was superseded or cancel()led.
$todos = $http({ url: "https://api.example.com/todos" })
function save() {
$res = $http({
url: "https://api.example.com/todos",
method: "POST",
body: { title: $draft, done: false }
})
$res.onDone = () => { $todos.refetch() } // re-pull the list once the write settles
$draft = ""
}
addBtn = Button("Add", { onClick: save, variant: "primary" })
Render the resource's states with Async($res, { loading, error, data }),
and see the Http guide for the full resource API.
For create/update/delete that should fire on a click (not on mount), prefer
$mutation({ url, method }) — a deferred write you trigger with
.mutate(overrides?). It also supports optimistic
updates (apply state instantly, auto‑rollback on failure) and
cache invalidation. See
Http → Mutations.
$addTodo = $mutation({
url: "https://api.example.com/todos",
optimistic: o => { $todos = [...$todos, o.body] },
invalidates: ["todos"]
})
Button("Add", { onClick: () => $addTodo.mutate({ body: { title: $draft } }) })
Async work, and what await does
An action body runs synchronously, start to finish, and the runtime paints once it returns. There is no suspension point: nothing in a body pauses and resumes later.
await parses, but it does not wait
Do not use await in an Aktion body. The
parser accepts the keyword so JavaScript-shaped output does not fail to
parse, but it never suspends.
As a bare statement at the top level of a body
(await doThing()) the call is dropped entirely
— doThing never runs. Inside an expression
(let p = await doThing()) the call does happen, but
p is the promise rather than its resolved value, the next
statement runs immediately, and a rejection escapes any surrounding
try/catch.
Reach for a promise chain, or better, for the reactive primitive that already owns the lifecycle:
$profile = "not loaded"
// ❌ `await` does not suspend: `p` holds the promise and the next line runs at once.
function loadWrong() {
let p = await fetch("/api/me")
$profile = String(p)
}
// ✅ Chain it: the callback writes state, and the write re-renders.
function loadRight() {
fetch("/api/me").then(r => r.json()).then(me => { $profile = me.name })
}
A state write from inside a .then / .catch /
setTimeout callback is a normal reactive write — it
schedules a re-render exactly like a write in the body itself. That is why
the chained form works and the awaited one does not.
$log = "nothing ran"
function bare() {
await Promise.resolve("bare await").then(v => { $log = v })
}
function chained() {
Promise.resolve("plain .then()").then(v => { $log = v })
}
$app(Column([
Text($log, { variant: "large-heavy" }),
Row([
Button("await doThing()", { onClick: bare, variant: "ghost" }),
Button("doThing().then(…)", { onClick: chained, variant: "primary" })
], { gap: "sm" })
], { gap: "md" }))
For anything that fetches, prefer $http /
$mutation over a hand-rolled chain: they expose
.loading and .error as reactive fields, so the UI
describes the in-flight state instead of you tracking it. Use
$util.copy(text), $util.sleep(ms) and friends the
same way — they return promises you chain with .then.
Confirming destructive actions
Host globals resolve by name inside an action body, so the browser dialogs
— confirm(...), alert(...),
prompt(...) — work directly under the default global
access policy. Guard a destructive write with confirm and bail
early when the user cancels:
$items = [{ id: 1, name: "Draft.txt" }, { id: 2, name: "Notes.md" }, { id: 3, name: "Plan.pdf" }]
function del(id) {
if (!confirm("Delete this item?")) { return }
$items = $items.filter(x => x.id != id)
}
$app(Card([Column($items.map(f => Row([
Text(f.name),
Spacer(),
Button("Delete", { onClick: () => del(f.id), variant: "danger", size: "sm" })
], { gap: "sm" })), { gap: "sm" })]))
Navigating
route is the reactive router handle — never declare it
yourself. Call route.navigate("/path") from inside an action to
change the URL and transition the matched view:
function openOrder(id) {
route.navigate("/orders/" + id)
}
row = Button("View", { onClick: () => openOrder(order.id), variant: "ghost" })
For matching, params, and query strings, see the Routing guide.
Emitting events to the host
Inside any action, $emit("name", detail) dispatches an outbound
CustomEvent on the <aktion-app> host element.
The page embedding the app listens with addEventListener. Pick
stable names; assistant-message, error, and
route-change are dispatched by the host element itself and are
documented with the rest of the host contract in
Frameworks → The contract.
function submit() {
$emit("form-submitted", { email: $email, name: $name })
}
// Host page:
el.addEventListener("form-submitted", (event) => {
console.log(event.detail.email);
});
The detail object is passed through unchanged, so the host reads
event.detail.email. Nothing in the program observes whether
anyone is listening — an unhandled $emit is a no-op.
When an action throws
A throw inside a handler is caught at the component boundary, logged as
[aktion] handler threw …, and swallowed — the page
keeps rendering rather than going blank. State written before the
throw stays written; there is no automatic rollback.
| Tool | Catches | Reach for it when |
|---|---|---|
$util.onError(fn) | A throw from a named action, as fn({ error, source }) where source is the declaration’s name. | You want one program‑level sink: a toast, or a report to your telemetry. |
$optimistic(() => { … }) | Nothing — it re‑throws. But it restores every atom to its pre‑callback value first. | The handler applies an optimistic write that must be undone if the work fails. |
ErrorBoundary(child, { fallback }) | A throw during render of its subtree, not a handler throw. | A risky component tree should degrade to a card instead of breaking the page. |
A DSL try/catch in the body | Anything the body throws synchronously. Not a promise rejection — nothing is awaited. | The failure has a local recovery: a fallback value, a retry, an inline error message. |
$util.onError only sees named actions
A throw from an inline lambda
(onClick: () => { … }) or from an
$effect body does not reach the sink. Those log
[aktion] handler threw … and
[aktion] effect "<name>" failed … to the console
instead.
To get a throw reported, move the body into a named
function. The sink wraps the declaration, so a lambda that
merely calls it — as in the demo below — is still
covered.
$rows = [{ id: 1, title: "Quarterly report" }]
$util.onError(({ error, source }) => {
$toast.error(`${source} failed: ${error}`)
})
function publish(id) {
$optimistic(() => {
$rows = $rows.filter(r => r.id != id)
if (id == 1) { throw "server rejected the publish" }
})
}
$app(Card([Column($rows.map(r => Row([
Text(r.title),
Spacer(),
Button("Publish", { onClick: () => publish(r.id), variant: "primary", size: "sm" })
], { gap: "sm" })), { gap: "sm" })]))
Clicking Publish removes the row, the throw rolls
$rows back, $util.onError raises the toast, and
the console still records [aktion] handler threw. Without the
$optimistic wrapper the row would stay gone. For the full
error model — strict mode, the host error event, parse
errors, and ErrorBoundary — see
Error handling → ErrorBoundary &
$util.onError.
A handler that returns a promise is not awaited. The
runtime attaches a rejection handler and logs
[aktion] handler rejected …, so a failed promise never
becomes an uncaught error — but it also never reaches
$util.onError. Put the failure path in an explicit
.catch that writes state.
Action vs effect vs binding
Three tools cover every "something should happen" case. Pick by what triggers it:
| Tool | Triggered by | Use for |
|---|---|---|
Action — function save() { … } | A user event (click, change, submit) | Mutations the user initiates: writes, server calls, navigation. |
Effect — $effect(() => { … }, [$dep]) | A dependency changing (or mount / interval) | Reactive side effects: debounced search, timers, subscriptions — see Side effects. |
Binding — $total = $util.sum($items) | Any read it depends on changing | Pure, derived reactive values. No side effects. |
Next
Side effects
$effect(...) for dependency-triggered work: timers, debounced search, cleanup.
JavaScript interactions
The full JS global surface, browser APIs, and host integration patterns inside action bodies.
Learn more → DataHttp
The reactive resource: .data .error .loading .refetch() .cancel() .onDone.