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:
- Inside a
functionbody (an action / event handler). - Inside an
$effectbody (a declarative side effect). - Inside a lambda — including the multi-statement
(x) => { …; return … }form.
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.
| Group | Globals | Notes |
|---|---|---|
| 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).
$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:
$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
Side effects
The $effect trigger grammar, cleanup, timers, and watched atoms.
Actions
The full function body grammar — state, HTTP, navigate, emit, return values.
Read the guide → ReferenceLanguage reference
Reactive state, expressions, control flow, and the $util helper namespace.