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
| Hook | Signature | When 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. |
propsis the reactive boundary. Bind$stateintopropsandupdatefires whenever any value changes — that is how the outside world drives the widget. Reading state insidesetup/updateis fine too.- The host is never rebuilt. Because the host is
preserved, re-renders never re-run
setupor recreate the element — the widget instance lives for the whole session. - Shallow compare.
{ data: $series }counts as changed only when$seriesis a new reference. Replace arrays/objects immutably (e.g.$series = [...]) to trigger an update. tagpicks the host element ("div","canvas","section", …). Defaults to"div".
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.
$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")
}
})
| Prop | Purpose |
|---|---|
tag required | The 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. |
children | Light-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 / option | Meaning |
|---|---|
.ready | true once the resource has loaded successfully — gate widgets on this. |
.loading | true while it is still downloading. |
.error | The load error, or null on success. |
.value | The resolved value: window[global] for a script with a global (e.g. window.Stripe), otherwise true. null until ready. |
src required | URL of the script or stylesheet. De-duplicated per src. |
global | Name of the window global the script defines — read into .value once ready. |
type / as | type: "module" for ESM; as: "style" forces a stylesheet (inferred for .css URLs). |
attributes | Extra 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.
| Method | What 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:
$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
| Use | When |
|---|---|
Mount | The library has an imperative JS API you call (new Chart(node, …), map.remove()) and you want a managed setup/update/cleanup lifecycle. |
WebComponent | The widget is already a custom element — you just need to place the tag and bind reactive attributes / properties / events. |
OnMount | You only need a one-shot node reference for a small imperative tweak (focus, scroll, a measurement) — no lifecycle, no teardown bookkeeping. |
Next
Side effects
The $effect() primitive — the dependency-driven counterpart for non-widget side effects and listeners.
Document head
$head(…) — reactive title, meta, Open Graph, and JSON-LD that also feeds SSR.
Component library
The full catalog of built-in components — reach for these before an interop escape hatch.
Browse components →