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.
$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:
| Form | Meaning |
|---|---|
$name = value | Reactive atom. A tracked cell — reading subscribes the surrounding component / effect, writing notifies subscribers and re-renders. |
name = value | Plain 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.
$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.
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())
-
$state(initial)returns a[value, setValue]pair.setValue(next)replaces the value;setValue(prev => next)derives it from the previous value. The initializer runs once, on first render. -
$memo(() => value, [deps])caches a value and recomputes it only when a dependency changes (shallowObject.is). Omit the deps array to recompute every render. -
$ref(initial)returns a stable{ current }box; writing.currentnever re-renders (useRef). -
$reducer((state, action) => next, initial)returns a[state, dispatch]pair for centralised state transitions (useReducer). -
$id(prefix?)returns a stable, unique id per instance — ideal for label/control wiring (useId).
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:
- Strings & template literals —
"hi",'hi', and backticks with${expr}interpolation. Prefer template literals over+concatenation:`Hi ${$user.name}, you have ${$util.count($todos)} todos`. - Numbers, booleans, null —
42,3.14,1_000_000,true/false,null. - Arrays & objects —
[1, 2, 3],{ name: "Ada", role: "Engineer" }; both may span multiple lines. - Operators — arithmetic (
+ - * / % **), comparison (== != === !== < <= > >=), logical (&& || !). - Ternary —
cond ? a : b; this is how you branch on a value (there is noifexpression). - Spread —
[...$a, ...$b],{ ...$cur, status: "done" }. - Member & optional chaining —
$user.email,$orders.data[0].id,$user?.profile?.name. - Nullish coalescing —
$user.name ?? "stranger".
$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 pluck —
rows.title returns the title of every row.
$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.
| Group | Methods |
|---|---|
| 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.
$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")
$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
Layout
Column, Row, and Grid — the three primitives behind every screen.
Read the guide → BehaviourActions
The full action body grammar: state writes, HTTP, navigation, and emit.
Read the guide → BehaviourSide effects
Effects, dependencies, polling, timers, and cleanup.
Read the guide → LibraryComponent reference
Every built-in component, prop, and enum, with a live preview.
Browse library →