Reference

Language.

Aktion is a strict subset of JavaScript with reactive semantics layered on top. Every program is valid JS — functions, arrows, template literals, spread, optional chaining — and the runtime adds two things: it makes $state reactive and it renders the component tree you pass to $app(…). If you know JavaScript, you already know almost all of this page.

Program structure

A program is a flat list of name = expression statements, one per line. The entry point is a call to $app(…) — whatever you pass to $app(…) becomes the rendered UI, normally a Column([…]) for a page or an AppShell(…) for an app. Every other binding is referenced from the $app(…) root directly or through other bindings.

Newlines terminate statements; semicolons are optional. Forward references resolve from the whole scope, so you can call $app(…) first and let the pieces it depends on stream in below it — the renderer re-parses each chunk and fills in names as they arrive.

A program does not have to live in one file. Once an app grows, split it into multiple .aktion files that import and export components, helpers, and reactive state — see Modules.

Live
$app(Column([header, body]))

header = PageHeader("Project Atlas", { subtitle: "Forward references resolve fine" })

body = Card([
  CardHeader("Overview"),
  Text("$app(...) is called first; header and body are defined below it.")
])

Three name conventions

The first character of a name carries meaning. There are exactly three conventions:

FormMeaning
$name = valueReactive atom. A tracked cell — reading subscribes the surrounding component / effect, writing notifies subscribers and re-renders.
name = valuePlain binding. A non-reactive alias captured once; reading it never subscribes.
function name()Component or action. A function declaration creates both: invoked in a render position its return value is rendered (a function with no return simply renders nothing); referenced from an event handler its body runs for side effects. First-letter case (Card vs card) is not significant. Reusing a built-in component's name is flagged by the validator — unless the body calls that same name (the wrapper pattern: inside its own body the name refers to the built-in, so function Badge(l) { return Badge(l, { tone: "success" }) } extends the library Badge instead of recursing).

The UI root is set by calling $app(…) (required, first line). Two top-level names are also reserved: theme (an optional $theme({…}) brand override), and route (the router-owned reactive handle — never declare it yourself).

Reactive state

There is one reactive primitive: the $-prefixed atom. The sigil is the contract. Reading a $atom subscribes whoever is reading; writing it wakes every subscriber and re-renders. Plain (sigil-less) bindings are never tracked.

Live
$count = 0
function inc() { $count = $count + 1 }
$app(Column([
  Text(`Count: ${$count}`, { variant: "large-heavy" }),
  Button("Increment", { variant: "primary", onClick: inc })
]))

Assignment is only legal inside function, effect, and lambda bodies — never in render position (top-level bindings, component output, prop values). All the JS assignment operators work against an atom: = += -= *= /= ??= ++ --. Member-target writes are first-class too — $user.name = "Alex" or $cart.items[0].qty += 1 — the runtime rebuilds the root reference immutably so subscribers always wake up. A $name declared inside a component body is per-instance: two siblings each get their own atom.

Subscriptions are fine-grained: dependencies track the exact path you read, not the whole atom. Reading $user.name subscribes to user.name alone, so a write to a sibling field ($user.role = …) won’t re-render, recompute, or re-fire it — while replacing the whole atom ($user = …, an ancestor) or writing a descendant still does. Object fields track field-by-field; reading into an array ($rows[i], the $rows.field pluck) or through a dynamic key subscribes at the array/container. The same rule drives computed values and effect dependencies ($effect(…, [$user.name])). No selectors, no special syntax — just read the path.

It also drives per-component re-rendering: a component re-executes only when its own inputs change — its args (props) or a $state path its body read — the granularity of React.memo / Solid, but automatic. Split a screen into focused components (ShowName($user.name), ShowAge($user.age)) and changing $user.age re-runs only ShowAge. Args are compared shallowly, so (as in React) a fresh inline-lambda prop each render makes the child re-render — hoist the handler to a stable binding to skip it. First-letter case does not matter: App and app seed state once and re-render identically.

Hooks

A function whose name starts with $ is a hook — the same use* convention you know from React. Hooks are the composable way to manage per-instance state. Five are built in: $state (useState), $memo (useMemo), $ref (useRef), $reducer (useReducer), and $id (useId). See the Hooks guide for the full reference and live examples.

Live
function Counter() {
  const [count, setCount] = $state(0)
  const label = $memo(() => `Count: ${count}`, [count])
  return Column([
    Text(label, { variant: "large-heavy" }),
    Button("Increment", { variant: "primary", onClick: () => setCount(c => c + 1) })
  ])
}
$app(Counter())

Declare your own hooks with function $name(...). The body runs inline in the calling component’s hook scope, so its $state / $memo calls attach to that component — exactly how a React custom hook shares its caller’s slots:

function $useToggle(initial) {
  const [on, setOn] = $state(initial)
  return { on: on, toggle: () => setOn(v => !v) }
}

function Panel() {
  const t = $useToggle(false)
  return Column([
    Button(t.on ? "Hide" : "Show", { onClick: t.toggle }),
    t.on ? Card([Text("Revealed")]) : null
  ])
}

Two rules carry over from React: call hooks unconditionally and in a stable order at the top level of a component / hook body (slots are matched by call order across renders), and note that hook state resets when the instance leaves the tree — a remounted component starts again from its initial value. $state, $memo, $ref, $reducer, and $id are reserved names. Reach for the hook form when a component owns local state with explicit setters; the bare $name = value per-instance form above is the lighter option when an atom is written directly by the component’s actions.

Expressions

The right-hand side of any binding is a value-producing JS expression:

Live
$value = 42
tone = $value > 0 ? "success" : ($value < 0 ? "danger" : "neutral")
$app(Column([
  Badge(`value is ${$value}`, { tone: tone }),
  Button("Flip sign", { onClick: () => { $value = -$value } })
]))

Building UI from data

To turn an array of data into an array of nodes, use the native .map / .filter — they always produce a new array you can hand to a container. Aktion also adds a few array conveniences: rows.length, rows.first, rows.last, and array pluckrows.title returns the title of every row.

Live
$people = [
  { name: "Ada Lovelace", role: "Engineer" },
  { name: "Alan Turing", role: "Researcher" },
  { name: "Grace Hopper", role: "Admiral" }
]
cards = $people.map(p => Card([
  Text(p.name, { variant: "large-heavy" }),
  Text(p.role, { tone: "muted" })
]))
$app(Column([
  Text(`${$people.length} people`, { tone: "muted" }),
  Column(cards, { gap: "sm" })
], { gap: "md" }))

Control flow

if / else, switch, for...of, while, and try / catch are JavaScript statements — they do not produce a value, so they cannot appear on the right-hand side of a binding. Use them inside function, effect, and lambda bodies. To branch in render position, use a ternary; to project an array, use .map / .filter; to compute a value from a switch, wrap it in a function and call it.

// ✅ Statements inside an action body
function submit(payload) {
  if (!payload.email) return
  for (let tag of payload.tags) $tags = [...$tags, tag]
  switch (payload.kind) {
    case "draft": $drafts = [...$drafts, payload]; break
    default:      $records = [...$records, payload]
  }
}

// ✅ Wrap a switch in a function to use it as a value
function panelFor(tab) {
  switch (tab) {
    case "billing": return billingPanel
    default:        return overviewPanel
  }
}
panel = panelFor($tab)

// ❌ Statements are not expressions
banner = if ($error) { Banner($error) }     // use a ternary instead
rows   = for (let r of data) { Row(r) }      // use data.map(r => Row(r))

$util — runtime helper namespace

Aktion exposes a single global $util object with pure, side-effect-free helpers for aggregation, reshaping, formatting, dates, math, and strings. Every method is callable from any expression position — render bodies, actions, effects, lambdas — and from ordinary host JavaScript that imports the bundle. New helpers can be added to $util over time without changing the language.

GroupMethods
Aggregation$util.count, $util.sum, $util.avg, $util.min, $util.max, $util.first, $util.last
Reshaping$util.filter, $util.find, $util.sort, $util.groupBy, $util.slice, $util.unique, $util.reverse, $util.range, $util.repeat, $util.pick
Formatting$util.format, $util.formatDate, $util.plural, $util.capitalize, $util.titlecase, $util.case
Date / time$util.now, $util.today, $util.addDays, $util.addHours, $util.diffDays, $util.startOfWeek, $util.endOfMonth
Math$util.round, $util.floor, $util.ceil, $util.abs, $util.clamp, $util.pow, $util.sqrt, $util.random, $util.log
String$util.join, $util.split, $util.trim, $util.replace, $util.substring, $util.startsWith, $util.endsWith, $util.contains, $util.match, $util.lowercase, $util.uppercase

Many $util helpers overlap with native JavaScript methods (arr.length, arr.filter(…), arr.map(…), Math.round(…)). Prefer the native form when it reads cleanly; reach for $util when you want field-based comparators ($util.filter(arr, "status", "==", "open")), locale-aware formatting ($util.format, $util.formatDate, $util.plural), or a quick range / repeat for skeleton UIs.

Live
$prices = [12, 8, 30, 5]
total = $util.sum($prices)
count = $util.count($prices)
$app(Column([
  Text(`${count} ${$util.plural(count, "item", "items")}, total ${total}`, { variant: "large-heavy" }),
  Row($util.range(1, count).map(n => Badge(`#${n}`, { tone: "primary" })), { gap: "sm" })
], { gap: "md" }))

Full JavaScript globals

Because Aktion is a subset of JavaScript, the entire JS global surface resolves by name inside function / effect / lambda bodies — no import, no escape hatch. That includes the standard library (Math, JSON, Object, Date, Map, …), browser dialogs (alert, confirm, prompt), and Web APIs (fetch, URL, URLSearchParams, crypto, navigator, Intl, BigInt, atob/btoa, …), plus window and document. Timers are available too: setTimeout, setInterval, clearTimeout, clearInterval — create them inside an effect and clear them in its cleanup.

function copyLink() {
  navigator.clipboard.writeText(window.location.href)
  $toast = "Link copied"
}

function remove(id) {
  if (confirm("Delete this item?")) $items = $items.filter(x => x.id != id)
}

id = crypto.randomUUID()

Your own declarations and built-in components always win over a same-named global — the passthrough only fills names you have not defined. Prefer the reactive $http({…}) over raw fetch for data that drives the UI. See the JavaScript interactions guide for the full bridge surface.

Component calls — three argument forms

Pick one form per call. The canonical form is Type(positional, { prop: value }) — the prop tagged (positional) bare, everything else in a trailing { } object. Calls may also be all-positional (arguments bind to the signature’s props in listed order) or all-named (a single { } object naming every prop). A lone object for a component whose positional prop is itself object-typed is that prop’s payload, and one object is never split between the two roles.

// ✅ Canonical: one positional + a trailing options object
Button("Save", { variant: "primary" })
Grid([Card1(), Card2(), Card3()], { columns: 3 })
StatCard("Revenue", { value: "$48k", trend: "up", delta: "+12%" })

// ✅ All-positional — binds in signature order
StatCard("Revenue", "$48k", "up")

// ✅ All-named — a single object naming the props
Button({ label: "Save", variant: "primary" })

// ⚠️ Signature order matters for all-positional calls:
// Button(label, onClick?, variant?, …) — so this binds "primary" to
// onClick, NOT variant. Prefer the trailing object for non-adjacent props.
Button("Save", "primary")
Live
$app(Grid([
  Card([Text("One")]),
  Card([Text("Two")]),
  Card([Text("Three")])
], { columns: 3, gap: "md" }))

Comments

Both JavaScript comment forms work and are ignored by the parser: line comments with // (including trailing comments like $count = 0 // counter) and block comments with /* … */, which may span multiple lines. Indentation is decorative — brackets and braces drive parsing — and blank lines are ignored.

// A line comment
$count = 0 // trailing comment on a statement

/* A block comment
   spanning several lines. */
$app(Text(`Count: ${$count}`))

Next