Behaviour

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 pressButton("Save", { onClick: save })
Typing / value changesInput("q", { onChange: (v) => $q = v })
A form submitForm({ 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.

Live — a lowercase declaration renders, a PascalCase one handles the click
$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 titleCallout(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.

Live — press Enter in a field, or click Sign in
$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.

Live
$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.

Live
$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:

Live
$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 entirelydoThing 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.

Live — the await button does nothing at all
$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:

Live
$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" })]))

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.

ToolCatchesReach 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 bodyAnything 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.

Live — the row is removed optimistically, then restored when the write throws
$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:

ToolTriggered byUse for
Actionfunction 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 changingPure, derived reactive values. No side effects.

Next