June 24, 2026
Micra + htmx: HTML over the wire, local state alongside
htmx and Micra sit on the same page without stepping on each other, because they answer different needs. htmx brings finished HTML from the server in response to an action — a click, a submit, a timer. Micra holds the state on that HTML the server has no reason to know about: whether a dropdown is open, what’s typed into an unsubmitted field, which tab is active. That lives in the browser, and there’s no request to make for it.
What htmx is good at
The browser has always done server-driven UI, but only through two elements: a link sends a GET, a form sends a GET or a POST, and in both cases the response replaces the whole page. htmx extends that same mechanism to everything else — any element can send a request, on any event, with any HTTP method, and the response replaces not the whole page but the piece of the DOM you point it at.
<button hx-post="/cart/add?id=42" hx-target="#cart" hx-swap="outerHTML">
Add to cart
</button>
The click sends a POST, the server returns the cart’s HTML, htmx swaps it in for #cart. You wrote no client-side code for this.
In a typical SPA the cart lives in two places at once. The server computes and stores it — and the client keeps its own copy in JavaScript: a model you fill from JSON, update on every change, and keep in sync with the server. A good share of front-end code is exactly that synchronization. htmx removes the copy: the cart stays only on the server, and the page shows precisely the HTML it sent back. There’s nothing to keep in sync, and so nothing to drift.
An element’s behavior is written on the element itself. What it does is visible right where you see the markup — no hunting for a handler across JS files. Carson Gross, htmx’s author, has an essay on this: locality of behaviour.
The model is richer than “click, swap”. Triggers take modifiers: hx-trigger="keyup changed delay:300ms" reacts to typing, but not on every keystroke and only when the value actually changed. Out-of-band swaps let one response update several parts of the page at once. hx-push-url puts a real URL in history, so the back button works. hx-boost turns ordinary links and forms into ajax updates without breaking them when JS is off. Response headers (HX-Trigger, HX-Redirect, HX-Retarget) let the server steer the client. Plus loading indicators, hx-sync for racing requests, and extensions for SSE and WebSocket.
htmx is mature: a stable 2.x, one script around 14 KB, backend-agnostic, a large community, a whole book — hypermedia.systems — on the approach. It covers a large class of apps: CRUD, admin panels, dashboards, content sites, forms — most of what the web actually does.
The seam it leaves is state that lives only in the browser and the server doesn’t need. That’s where Micra fits.
What belongs where
The line runs by who the state belongs to.
Server state is htmx’s. An order list, search results, the page’s contents: data the server computes and serves. htmx requests a fragment and swaps it into part of the DOM, with no separate front end.
Client and ephemeral state is Micra’s. Whether an accordion is open, which checkboxes are ticked, a form draft before it’s submitted. These values matter only to the user, and only right now.
When both are on one page, htmx delivers the markup and Micra adds the local interactivity on top.
The bridge
Since version 2.7.0 the bridge between them is three lines of code. htmx swaps the DOM, so there are two things to do: mount Micra on the HTML that arrives, and tear it down on the HTML that leaves.
Micra.start(); // mount what's already on the page
Micra.autoCleanup(); // tear a component down when htmx removes its DOM
// mount [data-component] elements that arrive in an htmx response
document.body.addEventListener("htmx:afterSettle", (e) =>
Micra.start(e.target),
);
Why two actions, not one. Micra.start() runs once on load and mounts what’s in the DOM at that moment. An htmx fragment that arrives later wasn’t in the DOM then, so its components are mounted separately — Micra.start(e.target) scans only the swapped subtree and brings them up. Repeat calls are safe: roots that are already mounted are skipped.
When htmx removes the old HTML, @event listeners go away with their nodes — they were on the elements themselves. But what a component added in onCreate on document or window — an outside-click handler for a dropdown, a global keydown for a modal — outlives the element’s removal and piles up with every swap. autoCleanup() watches the DOM and, when a component’s root leaves the page, calls its destroy(): onDestroy runs, and the bus subscriptions and exactly those global listeners are removed. This teardown used to be done by hand — a handler on htmx:beforeSwap that found the live instances in the outgoing HTML and called destroy on them. autoCleanup does the same on its own, with no separate handler.
Example: a list from the server, interactivity in place
The page asks the server for a list of cards:
<div hx-get="/cards" hx-trigger="load" hx-target="#list" hx-swap="innerHTML">
<div id="list"></div>
</div>
For each card the server returns a self-contained Micra island — the heading from the server, the expand and collapse from the client:
<article data-component="card">
<h3>Order #1204</h3>
<button @click="toggle" data-text="label()"></button>
<div data-show="open">
<!-- details -->
</div>
</article>
Micra.define("card", {
state: { open: false },
toggle() {
this.state.open = !this.state.open;
},
label() {
return this.state.open ? "Hide" : "Details";
},
});
htmx swaps #list, htmx:afterSettle fires, and Micra.start(e.target) mounts every card. From there, Details opens and closes the panel with no trip to the server — that’s the card’s local state. When the list refreshes again, autoCleanup() tears the old cards down for us.
A mistake to avoid
Don’t put hx-swap="innerHTML" on the very [data-component] that swaps its own contents:
<!-- don't do this -->
<div data-component="panel" hx-get="/panel" hx-swap="innerHTML">…</div>
After the first swap the panel instance is still alive, but its cached scan points at DOM that’s gone, and the new data-text / @click inside are invisible to it. The fix is a wrapper: the outer <div> is what swaps, and the Micra component inside is torn down and remounted by the bridge above.
<div
hx-get="/panel"
hx-trigger="every 30s"
hx-swap="innerHTML"
hx-target="this"
>
<div data-component="panel">…</div>
</div>
If you need the instance to survive the swap and keep its client state across a server refresh, do the opposite: put data-component on the outer element, and have hx-target swap something inside it that isn’t itself a component.
When one is enough
htmx covers plenty on its own: forms, lazy loading, fragment navigation, loading indicators. If a page has no client state, Micra isn’t needed there. And the other way around: if the page is static and the server doesn’t take part in updates, Micra alone — a script and a couple of directives — is enough.
One more thing makes them easy neighbors: both leave behavior on the element itself. For htmx that’s its locality of behaviour, mentioned above. For Micra it’s the same directives — @click="toggle", data-show="open" — where the markup shows what an element reacts to and what it touches, with no hunting for handlers by selector in a separate file. The difference is one of degree: an htmx attribute is self-contained, while a Micra directive points to a method whose body lives in the component object — but that object is one cohesive place, state and methods side by side. So a mixed page stays legible: the element shows what it does, whether the server handles it or the client holds it.
You want both where both halves exist: the server serves and updates the markup, and on top of it lives state that shouldn’t become a request. Each holds its half, and the line between them runs by who the state belongs to.