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.
| Prop | Type | Purpose |
|---|---|---|
setup required | callable | (node, props) => instance. The return value is the instance handle passed to update / cleanup. |
update | callable | (instance, props) => void, run when props (or deps) change. |
cleanup | callable | (instance) => void, run when the component leaves the tree. |
props | object | Reactive bag handed to setup / update. Bind $state here to drive the widget. |
tag | string | Host element, one of div, span, section, article, aside, figure, canvas, p, pre, form. Anything else falls back to div. Default "div". |
deps | any[] | Explicit dependency list gating update. When present, props is ignored for the change check — see When update runs. |
onError | callable | (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.
| 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 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. |
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. setupwaits for a connected host. If a commit hands the reconciler a host it discards in favour of an incumbent element,setupsimply retries on the next commit rather than initialising your widget inside a detached node. Each host carries a stabledata-rui-keyso the live element stays findable.- A throwing hook does not take the app down. The
error is logged and handed to
onError(err, stage)if you supplied one — render a fallback from state there rather than letting a failed map or payment element leave an empty box.
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.
$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, 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. |
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 }
})
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 to | Filter |
|---|---|
attributes + properties | Keys 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. |
attributes | srcdoc is skipped. It is a whole HTML document; there is no partial way to accept one. |
attributes | Six 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. |
properties | Sixteen 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 / 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. Set for a network failure, a rejected src, and a restricted access policy. |
.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, 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. |
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). 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.
src | Result |
|---|---|
https://js.stripe.com/v3/ | Accepted — http: and https: are the only permitted schemes. |
/vendor/sdk.js, ./sdk.js, ../sdk.js | Accepted — same-origin relative paths. |
//cdn.example.com/sdk.js | Rejected. 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.
| 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 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:
$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 element | Preserved 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.
| 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 → AdvancedUI Providers
Swap Aktion's visual layer to Material UI, Bootstrap, or ShadCN while keeping Aktion logic and state.
See provider patterns →