Let's make the worst htmx ever!

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, swap

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:

<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 triggers

We 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 targets

So 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 events

HTMX 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-... 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. Note how we propagate event details from one event to another, meaning that event handlers can modify the request and swap flow. For example, a plugin could intercept x:beforeSwap and change the target or swap mode based on some conditions or attributes.

Plugins

My initial impression from HTMX was that it does only one thing: if an event happens on some element with declarative configuration – send an AJAX request to some URL and swap the response into some other element. But if you check the reference you’ll see hx-on, hx-vals, hx-headers, hx-swap-oob, hx-params, hx-push-url, hx-history-elt, hx-sse, hx-ext and many more attributes that make it less straightforward to understand.

Can we achieve some feature parity without modifying the core “loop”? So that the “essence” of our library would be “SSS”: scan + send + swap.

For example, HTMX supports “boosting” – enhancing links and forms to use AJAX instead of full page reloads (to avoid writing x-get and x-target on each navigation link). This doesn’t even have to be a plugin, just a JS snippet:

document.addEventListener('click', e => {
  const boosted = e.target.closest('[x-boost]');
  if (!boosted) return;
  const link = e.target.closest('a');
  if (!link) return;
  const href = link.getAttribute('href');
  if (!href || href.startsWith('#') || link.getAttribute('target')) return;
  e.preventDefault();
  window.x.send(boosted, 'get', href);
});

Similarly we can “boost” forms on submit. There’s many more possible plugins to implement:

None of these requires any changes to the core x.js, and each plugin is a dozen lines of code, easy to reason about and to debug. Seems like event-based plugin architecture and “SSS” API work better than I hoped (and it’s still notably smaller than htmx).

And yet, to make it fully compatible with HTMX the core needs some extra work. We need proper error handling for failed fetch(), non-2XX responses etc. This likely would need more custom events to be fired. We also likely need to expose resolve() to plugins, so that they could use the same x-target syntax everywhere. We need some promise-based cancellation, too. One obvious use case would be custom confirmation dialogs, that need to be asynchronous. View transitions likely need to be asynchronous as well, so a plugin could delay a swap until the transition is done. I’m sure there’s more things that I’m missing.

Those are left as exercises for the reader. Or you can just use htmx?

Full code of this experiment is at https://github.com/zserge/x, if you spot any bugs or willing to contribute - please do so!

I hope you’ve enjoyed this article. You can follow – and contribute to – on Github, Mastodon, Twitter or subscribe via rss.

Jul 27, 2026

See also: Let's make the worst VueJS ever! and more.