The js{} escape hatch.
When the declarative surface doesn’t cover what you need
— clipboard writes, IntersectionObserver, third-party SDKs
— drop into a js{ … } block.
You get an async sandbox with access to state, host tools, and the
host element.
Where you can use it
js{ … } blocks are valid anywhere an imperative statement is valid:
- Inside an
actionbody. - Inside an
effectbody. - Inside a multi-statement lambda body (rare; prefer named actions).
action copyShareLink() {
js {
await navigator.clipboard.writeText(window.location.href)
ctx.state.set("copied", true)
}
}
effect [on:mount] {
js {
const onScroll = () => ctx.state.set("scrollY", window.scrollY)
window.addEventListener("scroll", onScroll, { passive: true })
ctx.cleanup(() => window.removeEventListener("scroll", onScroll))
}
}
The ctx bridge
Every js{} block runs as an async function
with one argument: ctx. The block can await
anywhere. The surface is intentionally narrow — just enough to
bridge the script and the host without leaking the entire runtime.
| Property | Type | Purpose |
|---|---|---|
ctx.state.get(name) | (name: string) => unknown | Read a reactive atom. Pass the bare name without $ — e.g. ctx.state.get("count") reads $count. |
ctx.state.set(name, value) | (name: string, value: unknown) => void | Write to a reactive atom. Triggers a re-render. |
ctx.cleanup(fn) | (fn: () => void) => void | Register a cleanup callback (effect bodies only). Runs when the effect re-fires or unmounts. |
ctx.host | HTMLElement | The host <aktion-app> element — useful for dispatchEvent, attribute reads, or accessing the shadow root. |
ctx.tools | Record<string, fn> | Host-registered async tools (set via el.setTools(...)). |
ctx.args | Record<string, unknown> | Action/lambda parameters by name — e.g. ctx.args.taskId. |
State naming
When reading and writing through ctx.state, drop the
sigil: use ctx.state.get("count") to read the
$count atom and ctx.state.set("count", 1)
to write it.
Common recipes
Clipboard
action copyToClipboard(text) {
js {
try {
await navigator.clipboard.writeText(ctx.args.text)
ctx.state.set("toast", { kind: "success", message: "Copied" })
} catch (err) {
ctx.state.set("toast", { kind: "error", message: err.message })
}
}
}
File input — read as data URL
action pickAvatar(file) {
js {
const reader = new FileReader()
reader.onload = () => ctx.state.set("avatarPreview", reader.result)
reader.readAsDataURL(ctx.args.file)
}
}
IntersectionObserver for infinite scroll
effect [on:mount] {
js {
const sentinel = ctx.host.shadowRoot?.querySelector("[data-sentinel]")
if (!sentinel) return
const observer = new IntersectionObserver((entries) => {
if (entries.some((e) => e.isIntersecting)) {
const page = ctx.state.get("page") || 1
ctx.state.set("page", page + 1)
}
})
observer.observe(sentinel)
ctx.cleanup(() => observer.disconnect())
}
}
WebSocket subscription
effect [on:mount] {
js {
const socket = new WebSocket("wss://example.com/stocks")
socket.addEventListener("message", (event) => {
ctx.state.set("ticker", JSON.parse(event.data))
})
ctx.cleanup(() => socket.close())
}
}
Keyboard shortcut listener
effect [on:mount] {
js {
const onKey = (e) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
ctx.host.dispatchEvent(new CustomEvent("toggle-palette", { bubbles: true, composed: true }))
}
}
document.addEventListener("keydown", onKey)
ctx.cleanup(() => document.removeEventListener("keydown", onKey))
}
}
Browser navigation guard
effect [on:mount] {
js {
const onBeforeUnload = (event) => {
if (ctx.state.get("isDirty")) {
event.preventDefault()
event.returnValue = ""
}
}
window.addEventListener("beforeunload", onBeforeUnload)
ctx.cleanup(() => window.removeEventListener("beforeunload", onBeforeUnload))
}
}
Reading lambda arguments
When an action receives arguments, they land in ctx.args
by name. This is handy for per-row action handlers.
action deleteTask(taskId) {
js {
const id = ctx.args.taskId
const remaining = (ctx.state.get("tasks") || []).filter(t => t.id !== id)
ctx.state.set("tasks", remaining)
}
}
# In the script:
Button("Delete", action: () => deleteTask(task.id))
Talking to a host-registered tool
Tools are the right primitive when the host has secrets, signed URLs, or framework-specific APIs the script shouldn’t touch directly. Register them once on the host:
// Host
el.setTools({
uploadFile: async ({ file }) => {
const body = new FormData();
body.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body });
return res.json();
},
fetchOrder: async ({ id }) => (await fetch(`/api/orders/${id}`)).json(),
});
Then call them from the script via ctx.tools:
action uploadAvatar(file) {
js {
const result = await ctx.tools.uploadFile({ file: ctx.args.file })
ctx.state.set("avatarUrl", result.url)
}
}
When NOT to use js{}
The declarative surface already covers most needs — prefer it whenever possible:
- Fetching data — use
http({...}), neverfetch(). - Navigation — use
_route_.navigate(path), neverwindow.location. - Persistence — use the
storageglobal directly (storage.set,storage.cookies.set, …). - Logging — use
console.log(...)directly — it’s an Aktion global. - Dispatching events — use
emit "name" { detail }from an action/effect body. - Side-effect chaining — use multiple
actioncalls or aneffectwith a state trigger.
Reach for js{} only when you need a browser API, a
third-party SDK, or a host integration that has no declarative
equivalent.
Security notes
- The body of
js{}is executed withnew Function(...). It runs in the host page’s realm, with full access to the page’s globals and DOM. - Only feed scripts from trusted sources. If you accept Aktion from arbitrary users, strip or refuse
js{}blocks before passing the text to the element. - Strict CSP environments may need
'unsafe-eval'forjs{}blocks to execute. The rest of the runtime works without it. - Errors thrown inside
js{}are caught and logged; they never crash the surrounding action or effect.
Next
Actions
The full body grammar — state, HTTP, navigate, emit, return values.
Read the guide → ReferenceLanguage reference
Reactive state, effects, control flow, built-in @-functions.
Framework integration
Register tools and wire events from React, Vue, Angular, Svelte, and plain HTML.
See recipes →