Let's Make the Worst Htmx

Tomte1 pts0 comments

Let's make the worst htmx ever!Let's make the worst htmx ever!<br>This is a continuation of a series of posts about building tiny &ldquo;clones&rdquo; of popular web frameworks.<br>Web is changing rapidly, and so the tools we use to build web apps (yet, I&rsquo;m sincerely happy that my React/Vue posts are now seven years old, and those frameworks are still popular and relevant).<br>Today the hot topic is htmx, basically a frontend library for backend developers who couldn&rsquo;t care less about JavaScript. HTMX started as intercooler, then evolved and grown more features, and is there is some beauty to it:<br>button hx-post="/clicked"<br>hx-trigger="click"<br>hx-target="#parent-div"<br>hx-swap="outerHTML"><br>Click Me!<br>/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.<br>Method, trigger, target, swap<br>Looking 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:<br>button x-get="/click">Click me/button>

document.querySelectorAll('[x-get]').forEach(el => {<br>el.addEventListener('click', async (e) => {<br>e.preventDefault();<br>const url = el.getAttribute('x-get');<br>const response = await fetch(url);<br>const data = await response.text();<br>el.outerHTML = data;<br>console.log(data);<br>});<br>});

If our backend sends &ldquo;Button clicked&rdquo; in response - it should work. Open a page, click the button, buttons disappears, text is shown instead. That&rsquo;s HTMX in 10 lines of code.<br>A few things to improve here. First, it&rsquo;s nice to sanitise the response to make sure it&rsquo;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:<br>const attr = (el, name) => el.closest(`[${name}]`)?.getAttribute(name);

const SWAP = {<br>outerHTML: (t, f) => t.replaceWith(f),<br>beforebegin: (t, f) => t.before(f),<br>afterbegin: (t, f) => t.prepend(f),<br>beforeend: (t, f) => t.append(f),<br>afterend: (t, f) => t.after(f),<br>delete: t => t.remove(),<br>none: () => {},<br>};

const swap = (mode, target, html) => {<br>const tpl = document.createElement('template');<br>tpl.innerHTML = html;<br>(SWAP[mode] || ((t, f) => t.replaceChildren(f)))(target, tpl.content);<br>};

Now we can write a more generic &ldquo;fetcher&rdquo; function to handle different methods, triggers, targets and swap modes:<br>const send = async (el, method, url) => {<br>const sel = attr(el, 'x-target');<br>const target = sel ? document.querySelector(sel) : el;<br>const mode = attr(el, 'x-swap') || 'innerHTML';<br>const opts = { method: method.toUpperCase(), headers: { 'X-Request': 'true' } };<br>if (el.matches('form')) opts.body = new URLSearchParams(new FormData(el));<br>const res = await fetch(url, opts);<br>swap(mode, target, await res.text());<br>};

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:<br>const METHODS = ['get', 'post', 'put', 'patch', 'delete'];

const defaultTrigger = el =><br>el.matches('form') ? 'submit' : el.matches('input,select,textarea') ? 'change' : 'click';

const scan = (root = document.body) =><br>METHODS.forEach(m =><br>root.querySelectorAll(`[x-${m}]`).forEach(el => {<br>if (el.$hx) return;<br>el.$hx = true;<br>const evt = attr(el, 'x-trigger') || defaultTrigger(el);<br>el.addEventListener(evt, e => {<br>e.preventDefault();<br>send(el, m, el.getAttribute(`hx-${m}`));<br>});<br>})<br>);

scan();<br>// we should also call scan() after each<br>// swap() inside send() at the end of it

And that&rsquo;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:<br>new MutationObserver(ms => {<br>for (const m of ms) m.addedNodes.forEach(n => { if (n.nodeType === 1) scan(n); });<br>}).observe(document.body, { childList: true, subtree: true });

Now it&rsquo;s small and performant.<br>Better triggers<br>We can introduce a custom syntax for triggers, allowing comma-separated list of events, with optional delays, &ldquo;changed&rdquo; or &ldquo;once&rdquo; modifiers, etc:<br>const parseTriggers = (s) => {<br>const triggers = s.split(',').map(s => s.trim());<br>return triggers.map(trigger => {<br>const [event, ...rest] = trigger.split(' ');<br>const options = {};<br>rest.forEach(opt => {<br>const [key, value = true] = opt.split(':');<br>options[key] =...

const swap target htmx rsquo button

Related Articles