Recipe: htmx + Micra.js
This recipe is the canonical answer to “I want htmx for server-driven HTML
swaps and Micra for client-side reactive islands on the same page”. As of
v2.7.0 the teardown half is automatic — Micra.autoCleanup() destroys any
component whose DOM htmx swaps out — so the whole bridge is one call plus one
line to mount the swapped-in HTML.
There is no
Micra.bridgeHtmx()helper.autoCleanup()handles teardown for any swap mechanism; mounting new content isMicra.start(subtree)on an htmx event. Wiring it yourself keeps the choices (which event, OOB swaps) in your hands.
The shape of the problem
htmx replaces DOM fragments via responses to hx-get / hx-post etc. Two
things go wrong if you do nothing:
- New
[data-component]elements arriving in the swapped HTML never mount.Micra.start()ran once on page load; the new elements were not in the DOM then. - Old Micra instances inside the replaced HTML leak. Their event-bus
subscriptions stay alive, their cached directive scan points at detached
DOM, and
onDestroynever runs.
The bridge fixes both: autoCleanup() tears down removed components
automatically; Micra.start() on htmx:afterSettle mounts the new ones.
1. The bridge — three lines, wire once
Put this once in your bootstrap script:
Micra.start() // mount what's already on the page
Micra.autoCleanup() // auto-destroy any component htmx swaps out
// Mount new [data-component] elements that arrive via an htmx swap.
document.body.addEventListener('htmx:afterSettle', (e) => Micra.start(e.target))
That’s it. Server returns HTML with <div data-component="x">…</div> inside it;
htmx swaps it in; htmx:afterSettle fires; Micra.start(e.target) scopes the
scan to the swapped subtree and mounts. Going the other way, when htmx removes
the old HTML, autoCleanup() sees the element leave the DOM and runs its
destroy() — onDestroy, bus unsubscribes, and listener cleanup all happen,
with no per-event wiring from you.
Micra.start() is idempotent — re-scanning a subtree that already has mounted
siblings is safe; they’re skipped.
Prefer explicit teardown?
If you’d rather not run a global observer, drop autoCleanup() and tear down the
outgoing subtree on htmx:beforeSwap instead. Micra.destroy() removes the
element’s component and every nested one in a single call:
document.body.addEventListener('htmx:beforeSwap', (e) => Micra.destroy(e.target))
2. Where to put data-component relative to hx-swap
The single most common footgun: putting hx-swap on a [data-component]
element that swaps its own innerHTML.
<!-- ❌ Don't do this -->
<div data-component="dashboard"
hx-get="/dashboard"
hx-trigger="every 30s"
hx-swap="innerHTML">
…
</div>
After the first swap, the Micra instance on dashboard is still alive but
its cached directive scan points at gone DOM. New data-text / @click
inside the swapped HTML are invisible to it.
Use a wrapper:
<!-- ✅ Wrapper swaps; Micra component is fully replaced -->
<div hx-get="/dashboard" hx-trigger="every 30s" hx-swap="innerHTML" hx-target="this">
<div data-component="dashboard">
…
</div>
</div>
Now the swap target is the outer <div>. The inner data-component is
destroyed-and-remounted by the bridge above on every refresh, with a clean
scan and fresh state.
If you genuinely need a Micra instance to survive across htmx swaps (keep
client state through a server refresh), invert it: put data-component on
the outer element and use hx-target to swap something inside that
isn’t itself a data-component.
<div data-component="filterable-list">
<input data-model="query" @input.debounce="search" />
<div id="results" hx-target="this" hx-swap="innerHTML">
<!-- server returns a fragment that may contain nested [data-component] -->
</div>
</div>
Here the outer instance keeps state.query across swaps. The inner
#results div is the swap target; the bridge handles any nested Micra
components that arrive in the response.
3. Bridging HX-Trigger to the Micra event bus
htmx lets the server fire client events via the HX-Trigger response
header. Forward them to Micra.emit() so any component (anywhere on the
page) can react — no special wiring per event.
document.body.addEventListener('htmx:trigger', (e) => {
// e.detail is the parsed HX-Trigger payload — a name or { name: payload }
const detail = e.detail
if (typeof detail === 'string') {
Micra.emit(detail)
return
}
for (const [name, payload] of Object.entries(detail)) {
Micra.emit(name, payload)
}
})
Server example (Rails):
response.headers['HX-Trigger'] = { 'cart:updated' => { count: @cart.size } }.to_json
Any Micra component subscribed via this.on('cart:updated', …) reacts —
the server is now a first-class emitter on the bus.
For type safety, declare the events your server sends in MicraEvents
(see API reference).
4. Sending Micra state to the server with hx-include / hx-vals
The opposite direction: an htmx request needs values that live in a Micra
component’s state. Two clean options.
Option A — render state into form inputs. htmx already includes form
inputs in the request via hx-include. Micra’s data-model keeps the
input in sync with state.
<div data-component="search-box">
<input name="q" data-model="query" />
<button hx-get="/api/search" hx-include="[name=q]" hx-target="#results">
Search
</button>
</div>
<div id="results"></div>
Option B — expose a getter on the instance and inject via hx-vals
expression. htmx evaluates hx-vals:'js:…' against the global scope.
Mark the button with a way to find the right instance (a data-ref on a
parent, or a stable id):
<button id="export-btn"
hx-post="/api/export"
hx-vals='js:{ ids: getSelectedIds() }'>
Export selected
</button>
// One global function, looks up the instance and forwards to a method.
window.getSelectedIds = () => {
const el = document.getElementById('table-root') // [data-component="rows"]
return Micra.instances().get(el)?.selectedIds() ?? []
}
Prefer Option A when the input is visible to the user. Option B is for cases where the input is computed (multi-select, derived totals, etc.).
5. Loading state without losing it across swaps
A common UX pattern: the search input lives in a Micra component, the
results are swapped via htmx, and you want a spinner on the input while
htmx is in flight. htmx fires htmx:beforeRequest / htmx:afterRequest —
forward them to the relevant Micra instance:
<div data-component="search" id="search">
<input data-model="query"
hx-get="/api/search"
hx-trigger="input changed delay:300ms"
hx-target="#results"
data-class="loading:busy" />
</div>
<div id="results"></div>
Micra.define('search', {
state: { query: '', busy: false },
onCreate() {
this.$el.addEventListener('htmx:beforeRequest', () => { this.state.busy = true })
this.$el.addEventListener('htmx:afterRequest', () => { this.state.busy = false })
},
})
Listeners attached in onCreate to this.$el (which is NOT replaced by
the swap — only #results is) survive across requests. No manual cleanup
needed since they die with the element.
Things to avoid
-
Don’t put
hx-swapon a[data-component]element that swaps its owninnerHTML. The cached directive scan points at gone DOM. See section 2 — use a wrapper. -
Don’t skip teardown. Mounting works on its own (idempotent), but without
autoCleanup()— or an explicitMicra.destroy()on swap-out — every swap leaks the previous instance’s bus subscriptions andonDestroynever runs. -
Don’t pass
documenttoMicra.start()inside the bridge. Always scope toe.target. Scanning the full document on every htmx response is wasteful and may double-traverse subtrees that haven’t changed. -
Don’t rely on
this.fetch()and htmx for the same request. Pick one path per element. Mixing — e.g.this.fetch()to load data, then htmx to swap the result — usually means you wanted plain htmx or plain Micra. -
Don’t forget the
htmx:triggerbridge is global by design. If you want a component-local channel, namespace the event (cart:updated, notupdated) so other components don’t react by accident.
Minimal full example
A page that boots once, then runs server-driven swaps with Micra islands inside:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="csrf-token" content="…">
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/micra.js/dist/micra.min.js"></script>
</head>
<body>
<main hx-get="/page/home" hx-trigger="load" hx-swap="innerHTML"></main>
<script>
// Define all components your server might render.
Micra.define('counter', {
state: { count: 0 },
inc() { this.state.count++ },
})
// Initial mount + automatic teardown when htmx swaps a component out.
Micra.start()
Micra.autoCleanup()
// Mount components that arrive in htmx-swapped HTML.
document.body.addEventListener('htmx:afterSettle', (e) => Micra.start(e.target))
// Optional: bridge HX-Trigger to the Micra bus.
document.body.addEventListener('htmx:trigger', (e) => {
const d = e.detail
if (typeof d === 'string') return Micra.emit(d)
for (const [k, v] of Object.entries(d)) Micra.emit(k, v)
})
</script>
</body>
</html>
Server returns fragments like:
<h1>Home</h1>
<div data-component="counter">
<button @click="inc">+</button>
<strong data-text="count"></strong>
</div>
…and the counter is fully reactive inside an htmx-swapped page.