Advanced

JavaScript interactions.

Aktion is a strict subset of JavaScript, and inside function, $effect, and lambda bodies you have the full JavaScript language and, by default, the entire browser global surface — dialogs, fetch, crypto, Intl, timers, window, and document. No imports, no wrappers: every global resolves by name. A host can narrow that surface deliberately — see What is not available.

Where you can use it

Aktion programs are declarative at the top level — you assign reactive state with $name and build the tree by composing components. Imperative JavaScript lives in three places, and in each of them the whole language is fair game:

State is read and written with the bare $ sigil — no bridge object, no getter/setter calls. Writing a $atom triggers a re-render.

Every one of those bodies runs synchronously: it executes top to bottom and the runtime paints once it returns. Asynchronous work is therefore always expressed as a callback.then, .catch, a setTimeout, an event listener — and a state write inside that callback is a normal reactive write that schedules its own re-render:

$copied = false

function copyShareLink() {
  $util.copy(window.location.href).then(ok => { $copied = ok })   // resolves true on a real copy
}

$effect(() => {
  const onScroll = () => { $scrollY = window.scrollY }
  window.addEventListener("scroll", onScroll, { passive: true })
  cleanup(() => window.removeEventListener("scroll", onScroll))
}, ["mount"])

$copied flips when the clipboard write settles, not when copyShareLink returns — and the scroll listener is removed by cleanup when the effect tears down.

await parses, but it never suspends

Do not use await in an Aktion body. The parser accepts the keyword so JavaScript-shaped output still parses, but bodies are not async functions.

A bare await doThing() statement at the top level of a body is dropped entirely — the call never happens. Inside an expression the call happens but the value is the promise, the next statement runs immediately, and a rejection escapes any surrounding try/catch. Chain the promise instead — see Actions → Async work.

The full global surface

Beyond the curated standard library, every JavaScript global resolves by name as the final fallback in identifier resolution. You never import anything — just use the name.

GroupGlobalsNotes
Dialogs alert, confirm, prompt Synchronous and blocking — fine for quick confirmations; prefer a Modal for rich UX.
Network fetch, URL, URLSearchParams, Blob, FormData For UI data prefer $http({…}) — it is reactive and re-fetchable. Reach for raw fetch only for one-off, non-rendering calls.
Crypto / IDs crypto.randomUUID(), crypto.getRandomValues() Generate stable client-side ids and random values.
Locale Intl.NumberFormat, Intl.DateTimeFormat Locale-aware number, currency, and date formatting.
Storage localStorage, sessionStorage Prefer the friendlier $storage global ($storage.set, $storage.cookies.set) — it JSON-roundtrips and swallows quota errors.
Encoding atob, btoa, encodeURIComponent, decodeURIComponent Base64 and URI escaping.
Stdlib Math, JSON, Object, Array, Number, String, Date, Map, Set, RegExp, Promise, BigInt, Reflect, parseInt The everyday toolbox — no import needed.
DOM roots window, document, navigator For DOM APIs with no declarative equivalent (clipboard, observers, listeners).
Timers setTimeout, setInterval, clearTimeout, clearInterval Runtime-tracked — see Timers below.

Prefer the declarative primitive when there is one

The passthrough exists for APIs the declarative surface doesn’t cover. For reactive UI data use $http({…}) rather than raw fetch; for persistence use the $storage global rather than raw localStorage. Reach for a bare global only when there is no declarative equivalent.

Browser dialogs

alert, confirm, and prompt work exactly like plain JavaScript and are handy for a quick yes/no from an action handler. They block the thread while open, so for anything richer than a confirmation prefer a Modal or Drawer component bound to state (see the component catalog).

Live — opens a real browser dialog
$msg = "—"
function ask() { $msg = confirm("Proceed?") ? "confirmed" : "cancelled" }
function rename() { let n = prompt("New name?", "Atlas"); if (n) { $msg = "renamed to " + n } }
$app(Column([
  Text($msg, { variant: "large-heavy" }),
  Row([
    Button("Confirm", { onClick: ask }),
    Button("Rename", { variant: "ghost", onClick: rename })
  ], { gap: "sm" })
], { gap: "md" }))

Timers

setTimeout, setInterval, clearTimeout, and clearInterval behave like their JavaScript counterparts and return a handle you can later clear. The difference: the runtime tracks every timer and tears it down automatically when the program re-plans or the surrounding scope unmounts, so a stray setInterval can never outlive the program. You should still clear an interval you no longer need.

The canonical pattern is to create the timer inside an $effect (not at the top level, which would re-create it on every render) and clear it in the effect’s cleanup:

$now = $util.now()

$effect(() => {
  let id = setInterval(() => { $now = $util.now() }, 1000)
  cleanup(() => clearInterval(id))
}, ["mount"])

clock = Text($util.formatDate($now, "time"))

The interval writes $now once a second and each write re-renders clock. Because the handle lives in a let inside the effect, the cleanup closure is the only thing that can clear it — which is exactly what you want.

For a debounce you need the handle to survive between calls, so keep it in an atom and declare it up front. An undeclared $searchTimer would read as null on the first keystroke and hide the mistake:

$query = ""
$searchTimer = null

function onType(q) {
  clearTimeout($searchTimer)
  $searchTimer = setTimeout(() => { $query = q }, 300)
}

Only the last keystroke in a 300 ms window reaches $query, so a search bound to $query fires once instead of once per character.

For a plain repeating effect, prefer the declarative $effect(…, ["every(1000)"]) trigger — reach for raw timers when you need an imperative handle, a one-shot delay, or a debounce/restart. See side effects for the full effect trigger grammar.

Recipes

Five patterns that come up constantly. Each is a complete action body — drop it into a program and wire it to a control.

Copy to clipboard

function copyToClipboard(text) {
  $util.copy(text).then(ok => {
    $toast = ok
      ? { kind: "success", message: "Copied" }
      : { kind: "error", message: "The browser blocked the copy" }
  })
}

$util.copy wraps the async Clipboard API and resolves true only when the write really succeeded, so a denied permission lands in the false branch rather than throwing. Prefer it to raw navigator.clipboard: it survives a missing API and keeps working when the host narrows the global surface.

Generate an id

function addRow() {
  $rows = [...$rows, { id: crypto.randomUUID(), label: "New row" }]
}

A stable id per row is what lets the reconciler follow a row when the list is reordered or filtered — see Reactivity for how keys are matched. $util.uuid() is the portable equivalent: it uses crypto.randomUUID when available and falls back to a generated v4 string when it is not.

Format a number

function formatTotal(n) {
  $total = Intl.NumberFormat("en-US").format(n)
}

Format at the edge, not in storage: keep n numeric in state and write the formatted string to a separate atom, so sorting and arithmetic still work on the raw value.

Read a file as a data URL

function pickAvatar(file) {
  const reader = new FileReader()
  reader.onload = () => { $avatarPreview = reader.result }
  reader.readAsDataURL(file)
}

readAsDataURL is asynchronous, so the write happens in onload — that callback re-renders on its own. Data URLs inline the whole file into the atom, so use them for previews, not for uploads.

Encode to Base64

function encode(text) {
  $encoded = btoa(text)
}

btoa is synchronous and throws on characters outside Latin‑1, so run text through encodeURIComponent first when it may contain non‑ASCII.

The synchronous helpers — crypto.randomUUID(), Intl.NumberFormat, and btoa — work directly in the preview, so this demo renders their results without any network access:

Live
$id = "—"
$money = "—"
$b64 = "—"
function run() {
  $id = crypto.randomUUID()
  $money = Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(1234567)
  $b64 = btoa("hello aktion")
}
$app(Column([
  Button("Run", { variant: "primary", onClick: run }),
  Text("id: " + $id),
  Text("money: " + $money),
  Text("base64: " + $b64)
], { gap: "sm" }))

Precedence & safety

The global passthrough is always tried last — after your state, bindings, actions, user-defined components, the curated standard library, and the component catalog.

Author declarations and built-in components therefore always win over a same-named global: a library Text or Map component still beats the DOM Text node or the Map constructor, and your own $crypto shadows the host crypto. The passthrough only fills in names you haven’t defined, so it can never silently change the meaning of a name you control.

What is not available

Everything above describes the default. The passthrough is a host setting, and there are two places where a global you expect will not be there: a narrowed access policy, and a server render.

Under a narrowed global access policy

By default the policy is "all", which makes an Aktion program exactly as privileged as a <script> tag on the same page. A host that runs program text it did not write calls setGlobalAccessPolicy("safe") (or passes an explicit array of names) at bootstrap, and the passthrough stops resolving anything outside that set:

Under "safe"Names
Blocked eval Function WebAssembly window self globalThis top parent document fetch XMLHttpRequest WebSocket EventSource Worker navigator localStorage sessionStorage indexedDB caches Reflect Proxy — and $script({ src }) is switched off entirely.
Still resolves The curated fast path — Math JSON Object Array Number String Boolean Date Map Set WeakMap WeakSet RegExp Symbol Promise Error parseInt parseFloat isNaN isFinite encodeURIComponent decodeURIComponent encodeURI decodeURI structuredClone — plus Intl, atob/btoa, URL, console, the timers, and every Aktion namespace ($http, $util, storage, $dom, $toast, …).

The curated names resolve before the passthrough, which is why they are unaffected: flipping the policy does not break ordinary value-crunching code. What it breaks is anything reaching for the DOM, the network, or persistence directly.

That failure is quiet. A blocked name does not throw; it simply does not resolve, so the expression behaves as if the identifier were undeclared. Turn on strict in development to see those lookups reported. The full allow-lists, the array form, and the rationale live in Security → Narrowing the global surface.

Three property names are always unreachable

.constructor, .__proto__ and .prototype cannot be read or written from a program — on any path, under every policy. A dotted read yields undefined, a computed read yields undefined, a method call yields null, and a write is a no-op. Without this, someLambda.constructor("…")() would rebuild eval and make the policy decorative.

During a server render

renderToString(program) needs a DOM, so a Node entry point has to register happy-dom or jsdom on globalThis first — without one it throws rather than rendering half a page.

That shim is not a browser. It implements a subset of the platform, there is no real clipboard, nothing has been laid out so there is nothing to measure, and capabilities such as matchMedia or localStorage exist only if the shim supplies them.

The load-bearing detail is that effects do not run during renderToString — the effect runner is part of the <aktion-app> element, not the SSR path. A ["mount"] body, an ["every(1000)"] tick, and any timer they start never fire on the server.

Top-level bindings and the component tree are evaluated, though, so that is where a browser-only read either throws or quietly yields a meaningless value:

// ❌ read during SSR: there is no viewport behind the shim
$width = window.innerWidth

// ✅ effects are client-only, so this never runs on the server
$width = 0
$effect(() => { $width = window.innerWidth }, ["mount"])

Anything that touches a browser capability belongs in an effect or an event handler, never in a top-level binding. See Deployment for the SSR and hydration seams.

Next