Skip to content
HN On Hacker News ↗

Let's make the worst htmx ever!

▲ 142 points 80 comments by RebelPotato 3w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this text is a mix of AI and human-written content.

15 %

AI likelihood · overall

Mixed
80% human-written 20% AI-generated
SEGMENTS · HUMAN 1 of 2
SEGMENTS · AI 0 of 2
WORD COUNT 1,266
PEAK AI % 57% · §2
Analyzed
Jul 31
backend: pangram/v3.3
Segments scanned
2 windows
avg 633 words each
Distribution
80 / 20%
human / AI fraction
Verdict
Mixed
Pangram v3.3

Article text · 1,266 words · 2 segments analyzed

Human AI-generated
§1 Human · 2%

This is a continuation of a series of posts about building tiny “clones” of popular web frameworks.Web is changing rapidly, and so the tools we use to build web apps (yet, I’m sincerely happy that my React/Vue posts are now seven years old, and those frameworks are still popular and relevant).Today the hot topic is htmx, basically a frontend library for backend developers who couldn’t care less about JavaScript. HTMX started as intercooler, then evolved and grown more features, and is there is some beauty to it:<button hx-post="/clicked" hx-trigger="click" hx-target="#parent-div" hx-swap="outerHTML"> Click Me! </button> In a declarative manner, it says: when the button is clicked, send a POST request to /clicked, and replace the outer HTML of the element with id #parent-div with the response data. HTMX wires together events, AJAX requests, and DOM updates hiding away frontend complexity. As long as your backend can send HTML templates you can go pretty far with it and build complete apps without a single line of JavaScript.Method, trigger, target, swapLooking at the example above, seems like our library should fetch() some URLs based on some triggers and update (swap) contents of some target elements. Something like this:<button x-get="/click">Click me</button> document.querySelectorAll('[x-get]').forEach(el => { el.addEventListener('click', async (e) => { e.preventDefault(); const url = el.getAttribute('x-get'); const response = await fetch(url); const data = await response.text(); el.outerHTML = data; console.log(data); }); }); If our backend sends “Button clicked” in response - it should work. Open a page, click the button, buttons disappears, text is shown instead. That’s HTMX in 10 lines of code.A few things to improve here. First, it’s nice to sanitise the response to make sure it’s HTML. Second, we should allow the user to specify a target element to swap content into. Third, we should allow the user to specify how exactly to swap the content in a target: replace, append, prepend, delete the element etc:const attr = (el, name) => el.closest(`[${name}]`)?.getAttribute(name); const SWAP = { outerHTML: (t, f) => t.replaceWith(f), beforebegin: (t, f) => t.before(f), afterbegin: (t, f) => t.prepend(f), beforeend: (t, f) => t.append(f), afterend: (t, f) => t.after(f), delete: t => t.remove(), none: () => {}, }; const swap = (mode, target, html) => { const tpl = document.createElement('template'); tpl.innerHTML = html; (SWAP[mode] || ((t, f) => t.replaceChildren(f)))(target, tpl.content); }; Now we can write a more generic “fetcher” function to handle different methods, triggers, targets and swap modes:const send = async (el, method, url) => { const sel = attr(el, 'x-target'); const target = sel ? document.querySelector(sel) : el; const mode = attr(el, 'x-swap') || 'innerHTML'; const opts = { method: method.toUpperCase(), headers: { 'X-Request': 'true' } }; if (el.matches('form')) opts.body = new URLSearchParams(new FormData(el)); const res = await fetch(url, opts); swap(mode, target, await res.text()); }; By default we update the content of the element, unless x-target or x-swap is specified. We also follow HTMX convention and send a custom header to the backend with the request. To bind it all together we add a simple event listener than dispatches the request based on the trigger:const METHODS = ['get', 'post', 'put', 'patch', 'delete']; const defaultTrigger = el => el.matches('form') ? 'submit' : el.matches('input,select,textarea') ? 'change' : 'click'; const scan = (root = document.body) => METHODS.forEach(m => root.querySelectorAll(`[x-${m}]`).forEach(el => { if (el.$hx) return; el.$hx = true; const evt = attr(el, 'x-trigger') || defaultTrigger(el); el.addEventListener(evt, e => { e.preventDefault(); send(el, m, el.getAttribute(`hx-${m}`)); }); }) ); scan(); // we should also call scan() after each // swap() inside send() at the end of it And that’s it, in 40 lines of code we get a working HTMX clone that support all HTTP methods, custom targets and swapping strategies. A small optimisation would be avoid re-scanning the entire DOM after each swap, but instead only scan the newly added content:new MutationObserver(ms => { for (const m of ms) m.addedNodes.forEach(n => { if (n.nodeType === 1) scan(n); }); }).observe(document.body, { childList: true, subtree: true }); Now it’s small and performant.Better triggersWe can introduce a custom syntax for triggers, allowing comma-separated list of events, with optional delays, “changed” or “once” modifiers, etc:const parseTriggers = (s) => { const triggers = s.split(',').map(s => s.trim()); return triggers.map(trigger => { const [event, ...rest] = trigger.split(' '); const options = {}; rest.forEach(opt => { const [key, value = true] = opt.split(':'); options[key] = value; }); return { event, options }; }); } This returns a list of events with options (keys/values, separated by colon in HTML attribute). Now we can write things like x-trigger="load, click one, change changed delay:500". We should also handle some events in our scan() function to handle them in a special way, i.e. load is basically setTimeout(() => send(...), 0), changed caches previous value and skips send if the value remains unchanged, once removes the event listener after the first trigger, and delay is another setTimeout with the specified delay. Your fantasy is the limit here, we could go as far as HTMX went and support viewport interaction triggers, queueing, debouncing, periodic events and much more. But we’re building a bad HTMX clone here.Better targetsSo far we use simple querySelector to find which element to swap content into. In practice, we might want to support a few more modifiers, like matching the closest ancestor or the following sibling.const resolve = (el, sel) => { if (!sel) return el; if (sel === 'this') return el; if (sel === 'next') return el.nextElementSibling; if (sel === 'previous') return el.previousElementSibling; if (sel === 'document') return document; if (sel === 'body') return document.body; if (sel === 'window') return window; if (sel.startsWith('closest ')) return el.closest(sel.slice(8)); if (sel.startsWith('find ')) return el.querySelector(sel.slice(5)); return document.querySelector(sel); }; We call it from send() instead of document.querySelector(sel) and we can now write things like x-target="closest .container" or x-target="next" to target more specific elements than a global query would target. HTMX does in fact roughly the same, if you want to inspect the code.Better eventsHTMX emits custom events like htmx:beforeRequest, allowing developers to intercept them, modify and control the request flow. We can do the same. In total we emit 4 events: beforeRequest, afterRequest, beforeSwap, afterSwap. Each event is emitted on the element that triggered the request, with full context of the current send(). Events can be cancelled by calling event.preventDefault().Let’s also try to support server-side events, like HX-Trigger or HX-Redirect. If the server sends a HX-Trigger header, we dispatch a custom event with the contents from that header. If server sent HX-Redirect, we simply redirect the browser to that URL. I kept HX prefix here, because X-...

§2 Mixed · 57%

headers is a common convention for standard HTTP headers.const send = async (el, method, url) => { let target = resolve(el, attr(el, "x-target")); let mode = attr(el, "x-swap") || "innerHTML"; const opts = { method: method.toUpperCase(), headers: { "HX-Request": "true" }, }; if (el.matches("form")) opts.body = new URLSearchParams(new FormData(el)); if (!fire(el, "x:beforeSend", { el, url, target, mode, opts }, true)) return; const response = await fetch(url, opts); const hdrTrigger = response.headers.get("HX-Trigger"); if (hdrTrigger) { try { const data = JSON.parse(hdrTrigger); Object.entries(data).forEach(([ev, d]) => fire(document.body, ev, d)); } catch { fire(document.body, hdrTrigger); } } if (response.headers.get("HX-Redirect")) { window.location.href = response.headers.get("HX-Redirect"); return; } if (response.headers.get("HX-Refresh") === "true") { window.location.reload(); return; } if (response.headers.get("HX-Retarget")) target = resolve(el, response.headers.get("HX-Retarget")); if (response.headers.get("HX-Reswap")) mode = response.headers.get("HX-Reswap"); const html = await response.text(); fire(el, "x:afterSend", { el, url, opts, target, mode, response, html }); const detail = { el, url, target, mode, html, response }; if (!fire(el, "x:beforeSwap", detail, true)) return; swap(detail.mode, detail.target, detail.html); fire(el, "x:afterSwap", detail); scan(detail.target); // rebind anything the swap just brought in }; Our final send() got bigger, but it allows us to write plugins now but intercepting requests and swaps.