The hx-live extension adds a small reactive scripting layer to htmx, inspired by _hyperscript. It introduces:
- an
hx-liveattribute that holds a JavaScript expression which automatically re-runs whenever the DOM changes, and - a compact
q()helper for selecting and manipulating elements, available both insidehx-liveexpressions and insidehx-onhandlers.
The goal is to cover the common “glue” cases — derived values, simple bindings, class juggling, conditional UI — without pulling in a full reactive framework.
Installing
<script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/htmx.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/ext/hx-live.min.js"></script> <body hx-ext="hx-live"> ... </body>
The hx-live Attribute
Put a JavaScript expression in hx-live. It runs once when the element is processed, and again whenever any input/change event or DOM mutation occurs.
<input id="name" value="world"> <output hx-live="this.textContent = 'hello, ' + q('#name').value"></output>
Inside the expression:
thisis the element.q(...)selects elements (see below).wait,trigger, anddebounceare also injected (see below).- The full htmx public API (
htmx.ajax,htmx.find, etc.) is available unprefixed.
How re-runs are triggered
The extension installs a single document-wide MutationObserver and listens for input and change events. Any of these schedule a recompute of all live expressions:
- DOM additions, removals, attribute changes, text changes anywhere in the document
- an
inputorchangeevent from any control - the completion of an htmx swap (recomputes are paused mid-swap and run once at the end)
All hx-live expressions on the page run on the same microtask, so multiple synchronous mutations coalesce into a single recompute.
If a live expression itself causes more than 50 recomputes per second, the extension assumes a self-mutating expression, logs a warning, and deactivates. Read or write more deliberately if you hit this — usually it means the expression is writing the same value on every run and tripping the mutation observer.
Coordinating with htmx swaps
While an htmx swap is in progress (between htmx:before:swap and htmx:swap:finally), recomputes are deferred. When the swap finishes, a single consolidated recompute runs. This means you can rely on hx-live expressions to see the post-swap DOM and run exactly once per swap, regardless of how much markup changed.
Cleanup
When an hx-live element is removed from the document, its expression is dropped from the active set on its next scheduled run. When the active set becomes empty, the mutation observer and event listeners are disconnected.
hx-ignore is honored: descendants of hx-ignore are not registered.
The q() Helper
q() returns a thin proxy over a set of elements. It is exposed both as a global (htmx.live.q) and inside the scope of hx-live and hx-on expressions.
Selecting
q('.foo') // every .foo in the document q('#bar') // a single element by id q(elt) // wrap an existing element q(nodeListOrArray) // wrap a collection
Selector grammar
q() accepts a small selector grammar on top of CSS:
q('first .foo') // first match q('last .foo') // last match q('next .foo') // first match that follows the current element q('prev .foo') // closest match preceding the current element q('closest .foo') // nearest ancestor matching .foo q('.foo in #scope') // restrict to a specific root q('.foo in this') // restrict to the current element
next, prev, and closest are relative to the element that owns the expression — i.e. this in hx-live/hx-on. They are only meaningful from inside an element-scoped expression; using them from the global htmx.live.q is undefined (there is no anchor element to resolve from).
Reading and writing
q('.row').count // number of matched elements q('.row').arr() // a real Array<Element> for (let e of q('.row')) {...} // iterate q('input').value // value of the first match q('input').value = '' // assign to every match
Property access and method calls chain through the proxy:
q('.row').classList.add('done') q('.row').dataset.state = 'on' q('button').click()
When you read a property, the proxy returns the value from the first element. When you assign, it writes to every element.
Chaining
Calling .q(...) on a proxy runs the same selector grammar again, with each matched element acting as both the anchor for directionals and the root for plain selectors. Results are flattened and deduplicated.
q('.error').q('closest .field') // for each .error, the surrounding .field q('section').q('first .item') // the first .item inside each section q('section').q('last .item') // the last .item inside each section q('.row').q('next .row') // each row's successor
For pure descendant queries plain CSS is shorter (q('.card .title') and q('.card').q('.title') are equivalent). The reason to chain is when you need a directional per matched element — particularly closest, which plain CSS can’t express cleanly.
Built-in shortcuts
A few common operations are first-class to keep call sites short:
q('.tab').trigger('select', { id: 1 }) // CustomEvent on each element q('.list').insert('end', '<li>new</li>') // before / after / start / end q('.tab.selected').take('selected', '.tab') // move a class from peers to self
take(class, from) removes class from every element matching from, then adds it to the elements in the proxy. This is the standard “tab selected” / “active filter” pattern.
Scope Helpers
Inside hx-live (and hx-on) expressions, several helpers are injected, bound to the current element where context applies. Each delegates to its htmx.* equivalent, so the same primitives are usable from regular JavaScript.
timeout(250) // resolves after 250ms (htmx.timeout) timeout('500ms') // string interval also accepted forEvent('click') // resolves on next 'click' on this element with the Event forEvent('click', 1000) // whichever happens first — event or timeout; // result is the Event (event won) or 1000 (timeout won) forEvent('a', 'b', '5s') // any number of events and timeouts; first to fire wins nextFrame() // resolves on the next animation frame trigger('myEvent', { x: 1 }) // dispatches a CustomEvent from this element debounce(200) // resolves in 200ms; if called again before then, // the previous call rejects take('selected', '.tab') // this element takes 'selected' from every .tab; // shorthand for q(this).take('selected', '.tab')
take(class, from) is the same operation as the proxy method, with the current element as the implicit target — the natural form for hx-on:click handlers. Outside an expression scope, use htmx.live.q(target).take(class, from).
hx-live bodies are evaluated as the body of an async function, so you can use await at the top level — no need to wrap in (async () => { ... })():
<output hx-live=" await debounce(200); this.textContent = await fetch('/q?term=' + q('#search').value).then(r => r.text()); "></output>
debounce is per-element, so successive recomputes of the same hx-live block supersede earlier in-flight calls — exactly what you want for live search.
Examples
Derived value
<input id="price" value="10"> <input id="qty" value="3"> <output hx-live="this.textContent = q('#price').valueAsNumber * q('#qty').valueAsNumber"> </output>
Conditional class
<input id="age" type="number" value="0"> <p hx-live="this.classList.toggle('warn', q('#age').valueAsNumber < 18)"> Adult content </p>
Live filter
<input id="filter" placeholder="filter…"> <ul> <li>apple</li><li>apricot</li><li>banana</li> </ul> <div hx-live=" let f = q('#filter').value.toLowerCase(); for (let li of q('li')) li.hidden = !li.textContent.toLowerCase().includes(f); "></div>
Tab selection (inside hx-on)
<nav> <button hx-on:click="take('selected', 'button in closest nav')">A</button> <button hx-on:click="take('selected', 'button in closest nav')">B</button> <button hx-on:click="take('selected', 'button in closest nav')">C</button> </nav>
Debounced live search
<input id="q" placeholder="search"> <output hx-live=" let term = q('#q').value; if (!term) { this.textContent = ''; return; } await debounce(250); this.textContent = await fetch('/search?q=' + encodeURIComponent(term)) .then(r => r.text()); "></output>
Public API
htmx.live.q is exposed for use outside of hx-live/hx-on:
htmx.live.q('.row').classList.add('loaded');
The selector directionals (next, prev, closest) need an anchor element to resolve from, so they are only meaningful inside hx-live/hx-on expressions, not from the global htmx.live.q.
htmx.live.refresh() triggers a recompute of every live expression on the page. Use this when an expression reads from a source the system cannot observe (a JavaScript variable, a getter, an external store) and you’ve just mutated that source — there’s no DOM event to drive the recompute, so you nudge it manually.
window.appState = 'loading'; htmx.live.refresh(); // pushes the new value out to any expression reading appState
Notes
- Live expressions run on any DOM mutation — the system intentionally does not do per-variable dependency tracking. The microtask coalescing keeps this cheap, but expensive expressions should opt into
debounceor guard themselves. - There is no JavaScript-variable reactivity. The DOM is the source of truth: read from the DOM, write to the DOM. To “share state” between expressions, use
data-*attributes or hidden inputs. hx-liverequires the expression to be safe to run repeatedly. Avoid side effects that aren’t idempotent (e.g. unconditionalfetch()calls) — usedebounceor guard on a value change.