Advanced

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:

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.

PropertyTypePurpose
ctx.state.get(name)(name: string) => unknownRead 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) => voidWrite to a reactive atom. Triggers a re-render.
ctx.cleanup(fn)(fn: () => void) => voidRegister a cleanup callback (effect bodies only). Runs when the effect re-fires or unmounts.
ctx.hostHTMLElementThe host <aktion-app> element — useful for dispatchEvent, attribute reads, or accessing the shadow root.
ctx.toolsRecord<string, fn>Host-registered async tools (set via el.setTools(...)).
ctx.argsRecord<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:

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

Next