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?, sx? }) 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.

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 (shallow-compared)
    instance.data = props.data
    instance.update()
  },
  cleanup: (instance) => instance.destroy(),  // runs on unmount
  props: { config: $cfg, data: $series }      // reactive bag handed to setup/update
})

The lifecycle contract

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 the props bag changed by a shallow compare (Object.is per key). 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. Destroy the widget / remove listeners so it can’t leak.

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 (must contain a hyphen). A hyphen-less name falls back to a div so a typo can’t crash the page.
attributes (alias attrs)Reactive attribute map. $state values update the element on change. false / null 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).
on (alias events)Event map { eventName: handler }. Bound once to the live element; handlers always read the latest closure, so they see current state.
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 }
})

$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.
.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. De-duplicated per src.
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).

The canonical pattern is “don’t render the widget until its SDK exists”:

$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()
  })
}

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

$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: { root?, rootMargin?, threshold? }.
$dom.onMutation(node, cb, options?)MutationObserver — cb(mutations). Options: { childList?, attributes?, subtree?, characterData? }.
$dom.measure(node)One-shot read → { rect, scroll, viewport } (bounding rect + scroll offsets + window size).

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)) }
))
// 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 })
}))

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

How preservation works (and SSR)

Every interop host carries data-rui-preserve. During reconciliation Aktion treats such a node as a black box: it never reconciles its children or resets form state, but it does push its own attribute changes additively (so a reactive sx, class, or data-* still flows) and keeps event handlers current. That is what lets a chart keep its canvas while you restyle its wrapper.

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 }
  })
}

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 }
  })
}

Mount vs. WebComponent vs. OnMount

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