Advanced

Third-party widgets & interop.

Almost every real app embeds at least one imperative library that owns its own DOM — Stripe Elements, Mapbox, a chart, Monaco, a video SDK, a captcha. Aktion gives you four first-class primitives to drop them in cleanly: Mount(…) for a managed lifecycle, WebComponent(…) for native custom elements, $script(…) to load an external SDK once, and $dom for managed resize / intersection / mutation observers.

Why these exist

Aktion components render declaratively: you describe the tree, the runtime reconciles the DOM, and it reuses + patches nodes on every re-render to preserve focus, scroll, and form state. That is exactly wrong for a widget that builds its own DOM — a chart canvas, a map’s tile layers, an editor’s document. If the reconciler touched those nodes it would corrupt or destroy the widget mid-session.

The interop primitives solve this in one move: the host element they create is marked data-rui-preserve, which tells the reconciler “this subtree is owned by imperative code — never reconcile its children or reset its form state.” Aktion keeps flowing its own attributes (a reactive sx / class) but otherwise leaves the live node alone. You get reactive data binding around a widget that fully owns the DOM inside.

Reach for interop only for real imperative libraries. If a chart, map, editor, or payment element can’t be expressed with built-in components, these are the escape hatch. For everything else, prefer the built-in component library — it reconciles, themes, and stays accessible for free.

Mount — managed imperative widgets

Mount({ setup, update?, cleanup?, props?, tag?, deps?, onError? }) is the managed host for any imperative widget. Aktion creates the host element; you fill it and react to prop changes through a clean three-phase lifecycle. setup is the only required prop.

chart = Mount({
  tag: "div",                          // host element (default "div")
  sx: { h: "320px" },                  // layout the host like any component
  setup: (node, props) => {            // runs ONCE after the host attaches
    return new Chart(node, props.config)  //   → return the instance handle
  },
  update: (instance, props) => {       // runs when `props` change (compared structurally)
    instance.data = props.data
    instance.update()
  },
  cleanup: (instance) => instance.destroy(),  // runs on unmount
  onError: (err, stage) => $failed = true,    // stage is "setup" or "update"
  props: { config: $cfg, data: $series }      // reactive bag handed to setup/update
})

Everything after setup is optional, but a widget that allocates anything — a canvas context, a websocket, a global listener — needs cleanup or it outlives the component.

PropTypePurpose
setup requiredcallable(node, props) => instance. The return value is the instance handle passed to update / cleanup.
updatecallable(instance, props) => void, run when props (or deps) change.
cleanupcallable(instance) => void, run when the component leaves the tree.
propsobjectReactive bag handed to setup / update. Bind $state here to drive the widget.
tagstringHost element, one of div, span, section, article, aside, figure, canvas, p, pre, form. Anything else falls back to div. Default "div".
depsany[]Explicit dependency list gating update. When present, props is ignored for the change check — see When update runs.
onErrorcallable(err, stage) => void, fired when setup or update throws. stage is "setup" or "update". The error is also logged.

The lifecycle contract

All three hooks run on a microtask, never inside the reconcile pass, so your widget code always sees an attached DOM node and can safely trigger state changes of its own.

HookSignatureWhen it runs
setup required (node, props) => instance Once, on a microtask right after the host element is attached to the document. Build your widget here and return the instance handle — it is passed back to update and cleanup.
update (instance, props) => void On every re-render where props (or deps) changed. Push the new data into the live widget. Deferred to a microtask, so it runs after the reconcile pass.
cleanup (instance) => void Once, when the component leaves the render tree — and also when a reactive tag changes, because that replaces the host element. Destroy the widget / remove listeners so it can’t leak.

When update runs

The DSL rebuilds every object and array literal on each evaluation, so comparing the props bag by reference would fire update on every unrelated keystroke in the app. Aktion compares it structurally, four levels deep instead: plain objects and arrays are walked, and anything else — a widget handle, a class instance, a DOM node — is compared by identity.

$series = [12, 19, 7, 15]
$clicks = 0

function SalesChart() {
  return Mount({
    sx: { h: "240px" },
    // `config` is a brand-new literal on every render. A reference compare
    // would re-run `update` whenever $clicks changed; the structural compare
    // sees identical contents and skips it.
    props: { config: { type: "bar", data: { values: $series } } },
    setup: (node, props) => new Chart(node, props.config),
    update: (chart, props) => chart.setOption(props.config),
    cleanup: (chart) => chart.destroy()
  })
}

$app(Column([
  SalesChart(),
  Row([
    // Re-renders the app, but the chart's props are unchanged → no `update`.
    Button(`Unrelated re-render (${$clicks})`, { onClick: () => $clicks = $clicks + 1 }),
    // New numbers inside `data.values` → `update` runs once.
    Button("Shuffle data", { onClick: () => $series = $series.map(() => Math.round(Math.random() * 20)) })
  ], { gap: "sm" })
]))

Two cases defeat the structural compare, and both are what deps is for: data nested deeper than four levels, and a mutation made in place ($series.push(1)) that leaves the structure comparing equal. Pass an explicit list and props stops being consulted for the decision:

chart = Mount({
  setup: (node, props) => new Chart(node, props.config),
  update: (chart, props) => chart.setOption(props.config),
  cleanup: (chart) => chart.destroy(),
  props: { config: $deeplyNestedConfig },
  deps: [$revision]                     // bump $revision to force an update
})

With deps present, update runs exactly when a value in the list changes — props is still handed to the callback, it just no longer decides whether the callback fires.

Live: a self-contained canvas

No external library needed — setup grabs the canvas 2D context and draws. This is the whole pattern in miniature: Aktion owns the <canvas>, your code owns the pixels.

Live
$app(Column([
  Text("Drawn by an imperative Mount:", { sx: { weight: "600" } }),
  Mount({
    tag: "canvas",
    sx: { w: "100%", h: "140px", radius: "md" },
    setup: (node) => {
      node.width = 600; node.height = 280
      ctx = node.getContext("2d")
      ctx.fillStyle = "#6366f1"
      ctx.fillRect(20, 20, 200, 90)
      ctx.fillStyle = "#22c55e"
      ctx.beginPath()
      ctx.arc(360, 120, 70, 0, Math.PI * 2)
      ctx.fill()
      return ctx
    }
  })
]))

Full example: Chart.js with reactive data

Load the library with $script, gate the Mount on it being ready, then push reactive series in through props:

$chartjs = $script({ src: "https://cdn.jsdelivr.net/npm/chart.js", global: "Chart" })
$series  = [12, 19, 7, 15]

function SalesChart() {
  if (!$chartjs.ready) {
    return Skeleton({ sx: { h: "320px" } })   // graceful loading state
  }
  return Mount({
    sx: { h: "320px" },
    setup: (node, props) => new $chartjs.value(node, {
      type: "bar",
      data: { labels: ["Q1","Q2","Q3","Q4"], datasets: [{ data: props.series }] }
    }),
    update: (chart, props) => {
      chart.data.datasets[0].data = props.series
      chart.update()
    },
    cleanup: (chart) => chart.destroy(),
    props: { series: $series }
  })
}

$app(Column([
  SalesChart(),
  Button("Shuffle", { onClick: () => $series = $series.map(() => Math.round(Math.random() * 20)) })
]))

WebComponent — native custom elements

Many third-party widgets already ship as custom elements (<stripe-pricing-table>, <model-viewer>, a design-system element). WebComponent(tag, { attributes?, properties?, on?, children? }) renders and hydrates one with reactive attributes, rich JS properties, and event hooks.

widget = WebComponent("stripe-pricing-table", {
  attributes: {                         // reactive — re-applied on $state change
    "pricing-table-id": $id,
    "publishable-key":  $pk
  },
  on: {                                 // listeners stay current across renders
    "checkout": e => route.navigate("/thanks")
  }
})
PropPurpose
tag requiredThe custom-element tag, positional and must contain a hyphen (/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/). A hyphen-less name falls back to a div so a typo can’t crash the page — and warns once on the console, because a silent empty box is impossible to diagnose.
attributes (alias attrs)Reactive attribute map. $state values update the element on change. false / null / undefined removes the attribute; true sets it empty. on* keys are ignored (use on).
properties (alias props)JS properties assigned directly on the element — for components that take rich, non-string values (objects, arrays, functions). Built-in DOM property names are refused; see What is filtered.
on (alias events)Event map { eventName: handler }. Bound once to the live element; handlers always read the latest closure, so they see current state. An entry that only appears on a later render is subscribed then.
childrenLight-DOM child nodes / text slotted inside the element.

Use attributes for simple string-ish values and properties when the element exposes a rich property API:

viewer = WebComponent("model-viewer", {
  attributes: { src: $modelUrl, "camera-controls": true, ar: true },
  properties: { cameraOrbit: $orbit },   // a rich, non-string property
  on: { "load": () => $loaded = true }
})

What is filtered

attributes and properties both land on a real DOM node, so both are filtered before they are applied. Your element’s own attributes and properties pass through untouched — only names the platform already owns are affected.

Applies toFilter
attributes + propertiesKeys starting with on are skipped. Bind events through on instead, where a handler is a real function rather than a string the host page evaluates.
attributessrcdoc is skipped. It is a whole HTML document; there is no partial way to accept one.
attributesSix URL-valued attributes are sanitised: href, action, formaction and ping through the anchor href chokepoint, src and poster through the image chokepoint. A value that does not survive removes the attribute rather than setting it.
propertiesSixteen built-in DOM property names are blocked, matched case-insensitively: innerHTML, outerHTML, insertAdjacentHTML, srcdoc, src, href, action, formaction, style, id, attributes, shadowRoot, contentEditable, constructor, __proto__, prototype.

The practical consequence: set a URL through attributes: { src: $url }, where it is sanitised, not through properties, where it is refused. Style the element with sx rather than a style property, and slot markup with children rather than an innerHTML property — WebComponent("x", { properties: { innerHTML: … } }) would otherwise be a direct script-execution sink. The reasoning behind each list is on Security & the trust model.

$script — load an external SDK once

$script({ src, global? }) loads an external UMD/ESM script (or stylesheet) exactly once per src across the whole app, and returns a reactive bag you gate your UI on.

$stripe = $script({ src: "https://js.stripe.com/v3/", global: "Stripe" })
// → { ready, loading, error, value }   (value = window.Stripe once loaded)
Field / optionMeaning
.readytrue once the resource has loaded successfully — gate widgets on this.
.loadingtrue while it is still downloading.
.errorThe load error, or null on success. Set for a network failure, a rejected src, and a restricted access policy.
.valueThe resolved value: window[global] for a script with a global (e.g. window.Stripe), otherwise true. null until ready.
src requiredURL of the script or stylesheet, http(s) or same-origin — see Which URLs are accepted. De-duplicated per src for the lifetime of the document, across every program on the page.
globalName of the window global the script defines — read into .value once ready.
type / astype: "module" for ESM; as: "style" forces a stylesheet (inferred for .css URLs).
attributesExtra attributes for the injected <script> / <link> (e.g. crossorigin, integrity). on* keys are skipped; true sets an empty attribute.

The canonical pattern is “don’t render the widget until its SDK exists”, with an explicit branch for the failure case:

$maps = $script({ src: "https://maps.example.com/sdk.js", global: "MapSDK" })

function MapView() {
  if ($maps.error)  return Alert("Map failed to load", { variant: "danger" })
  if (!$maps.ready) return Spinner()
  return Mount({
    sx: { h: "400px" },
    setup: (node) => new $maps.value.Map(node, { center: [0, 0], zoom: 2 }),
    cleanup: (map) => map.remove()
  })
}

Branch on .error first, then .ready: every way a load can fail — a network error, a rejected URL, a disabled $script — sets .error and leaves .ready false, so a program written this way degrades instead of rendering nothing.

On the server (renderToString) there is no DOM to inject into, so the bag stays { ready: false, error: null } and your UI falls back to the loading branch — exactly what you want for an SSR placeholder that hydrates on the client.

Which URLs are accepted

$script exists to load a real external file, so src must be an http: / https: URL or a same-origin path. Control characters are stripped before the check.

srcResult
https://js.stripe.com/v3/Accepted — http: and https: are the only permitted schemes.
/vendor/sdk.js, ./sdk.js, ../sdk.jsAccepted — same-origin relative paths.
//cdn.example.com/sdk.jsRejected. Protocol-relative URLs inherit the page’s scheme and read as a path at a glance.
javascript:…, data:…, blob:…Rejected. These are inline code wearing a URL, not an external script.

A rejected src does not throw. The bag comes back with ready: false and error set to:

[aktion] $script requires a `src` that is an http(s) or same-origin URL.

So the symptom is a widget stuck on its loading branch. When that happens, read the bag’s .error$maps.error above — before suspecting the network.

$script and the global access policy

$script downloads and executes remote code, which is exactly what a host narrowing the runtime’s global surface is trying to prevent. So it is disabled outright — not merely restricted — whenever the host has called setGlobalAccessPolicy with anything other than the default "all".

// Host bootstrap, before mounting the app:
import { setGlobalAccessPolicy } from "aktion-runtime";
setGlobalAccessPolicy("safe");    // → every $script in every program now fails

No request is made and the bag returns immediately with ready: false and this error:

[aktion] $script is disabled because a restricted global access policy is active (see setGlobalAccessPolicy). It loads and executes remote code.

Why an integration can stop working

The policy is process-global. One setGlobalAccessPolicy call anywhere in the host page disables $script for every program on it, and nothing about your program changed. If a previously working Mount + $script pair suddenly never leaves its skeleton, check the policy before anything else — Security & the trust model covers when you want it narrowed and what else it changes. Load the SDK from the host page and hand it in through props if you need both.

$dom — managed observers

Migrating resize / intersection / mutation logic usually means hand-rolling an observer plus its teardown in an $effect. The $dom namespace does the bookkeeping: every observer it creates is auto-disposed on re-plan / unmount, and each method returns a disposer so you can stop early.

MethodWhat it does
$dom.onResize(node, cb)ResizeObserver — cb({ width, height, entry }) on size change. Returns a disposer.
$dom.onIntersect(node, cb, options?)IntersectionObserver — cb(entry) on visibility change. Options are passed straight through: { root?, rootMargin?, threshold? }.
$dom.onMutation(node, cb, options?)MutationObserver — cb(mutations). Options default to { childList: true, attributes: true, subtree: false, characterData: false }, so pass { subtree: true } to watch descendants.
$dom.measure(node)One-shot read → { rect, scroll, viewport } (bounding rect + scroll offsets + window size). Returns null for a non-element.

Each callback also re-renders the app, so writing state from inside one is the normal way to surface a measurement. If the node is not an element — or the browser lacks that observer — you get a no-op disposer instead of an exception, so a defensive guard is unnecessary.

Pair these with a node reference from OnMount or a Mount host:

Live — resize the window
$w = 0
$app(OnMount(
  Box([ Text(`Container width: ${$w}px`, { sx: { weight: "600" } }) ], { sx: { p: "20px" } }),
  { onMount: (node) => $dom.onResize(node, ({ width }) => $w = Math.round(width)) }
))
$seen = false

// Lazy-load an image / fire analytics when a section scrolls into view:
$app(OnMount(Box([ Text("Reveal me") ]), {
  onMount: (node) => {
    $dom.onIntersect(node, (entry) => {
      if (entry.isIntersecting) $seen = true
    }, { threshold: 0.25 })                  // fires once 25% is visible

    // One-shot measurement, no observer:
    size = $dom.measure(node)                // → { rect, scroll, viewport }
  }
}))

Declaring $seen up front matters: an undeclared identifier reads as null rather than raising, so a typo in an observer callback fails silently. measure is a plain read — call it whenever you need current geometry instead of subscribing.

How preservation works (and SSR)

Every interop host carries data-rui-preserve. On each commit Aktion re-renders the whole tree and reconciles it against the live DOM; a node carrying that flag on either side of the diff takes a reduced path instead of the normal one.

Normal elementPreserved host
Attributes are made to match the fresh render — ones the fresh tree omits are removed.Attributes are synced additively: new and changed ones are applied, and anything the widget reflected onto itself is left alone.
Children are reconciled against the fresh tree.Children are never touched. The widget owns them.
Form state (value, checked) is synced.Form state is never touched — a preserved control is the widget’s to manage.
Event handlers are refreshed to the current closures.Event handlers are refreshed the same way.

That is what lets a chart keep its canvas while you restyle its wrapper: a reactive sx, class, or data-* still flows in, and nothing flows out.

Canvas dimensions are element-owned

Sizing a <canvas> in setup is safe. The reconciler treats width / height on a canvas as element-owned even outside a preserved subtree, because removing them would reset the drawing buffer to 300×150 and erase the bitmap. So node.width = 600 survives every later commit — unless a fresh render explicitly sets different values.

Because the widget’s DOM is built imperatively on the client, interop hosts render as an empty placeholder during renderToString and come alive on hydration. Combine that with a !ready loading branch and your SSR output is a clean skeleton that fills in client-side — see Production & deployment.

Recipes

Monaco editor with two-way binding

$monaco = $script({ src: "https://cdn.example.com/monaco/loader.js", global: "monaco" })
$code   = "function hi() {}"

function Editor() {
  if (!$monaco.ready) return Skeleton({ sx: { h: "300px" } })
  return Mount({
    sx: { h: "300px", border: true, radius: "md" },
    setup: (node, props) => {
      ed = $monaco.value.editor.create(node, { value: props.value, language: "javascript" })
      ed.onDidChangeModelContent(() => $code = ed.getValue())
      return ed
    },
    update: (ed, props) => { if (props.value !== ed.getValue()) ed.setValue(props.value) },
    cleanup: (ed) => ed.dispose(),
    props: { value: $code }
  })
}

The update guard is the important line: without the props.value !== ed.getValue() check, every keystroke would write the editor’s own value back into it and reset the caret.

Stripe payment element

$stripe = $script({ src: "https://js.stripe.com/v3/", global: "Stripe" })

function Checkout() {
  if (!$stripe.ready) return Spinner()
  return Mount({
    sx: { minH: "60px" },
    setup: (node, props) => {
      stripe = $stripe.value(props.pk)
      elements = stripe.elements({ clientSecret: props.secret })
      card = elements.create("payment")
      card.mount(node)
      return { stripe, elements, card }
    },
    cleanup: (inst) => inst.card.destroy(),
    props: { pk: $publishableKey, secret: $clientSecret }
  })
}

Returning the whole { stripe, elements, card } triple is the pattern to copy when teardown needs more than one handle — cleanup receives exactly what setup returned.

Mount vs. WebComponent vs. OnMount

All three hand you a live DOM node; they differ in how much bookkeeping they do for you. Pick the least powerful one that fits.

UseWhen
MountThe library has an imperative JS API you call (new Chart(node, …), map.remove()) and you want a managed setup/update/cleanup lifecycle.
WebComponentThe widget is already a custom element — you just need to place the tag and bind reactive attributes / properties / events.
OnMountYou only need a one-shot node reference for a small imperative tweak (focus, scroll, a measurement) — no lifecycle, no teardown bookkeeping.

Next