Home > Article > Web Front-end > I replaced htmx with a simple web component
(Image credit: https://www.maicar.com/GML/Ajax1.html)
I recently had a conversation on Mastodon about how I was using htmx to much success, and someone rolled into my mentions challenging me on that, and how htmx is actually a pretty heavy dependency considering what I was using it for. They linked me to this post and everything.
At first, I was kind of annoyed. I thought I was doing a pretty good job of keeping things lightweight, and htmx had served me well, but then I put on the hat that I've been trying to wear this whole time when it comes to reinventing the way I do web dev: are my assumptions right? Can I do better?
So I went ahead and replace my entire usage of htmx with a tiny, 100-line, vanillajs web component, that I'm going to include in this post in its entirety:
export class AjaxIt extends HTMLElement { constructor() { super(); this.addEventListener("submit", this.#handleSubmit); this.addEventListener("click", this.#handleClick); } #handleSubmit(e: SubmitEvent) { const form = e.target as HTMLFormElement; if (form.parentElement !== this) return; e.preventDefault(); const beforeEv = new CustomEvent("ajax-it:beforeRequest", { bubbles: true, composed: true, cancelable: true, }); form.dispatchEvent(beforeEv); if (beforeEv.defaultPrevented) { return; } const data = new FormData(form); form.dispatchEvent(new CustomEvent("ajax-it:beforeSend", { bubbles: true, composed: true })); const action = (e.submitter as HTMLButtonElement | null)?.formAction || form.action; (async () => { try { const res = await fetch(action, { method: form.method || "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Ajax-It": "true", }, body: new URLSearchParams(data as unknown as Record<string, string>), }); if (!res.ok) { throw new Error("request failed"); } form.dispatchEvent(new CustomEvent("ajax-it:afterRequest", { bubbles: true, composed: true })); const text = await res.text(); this.#injectReplacements(text, new URL(res.url).hash); } catch { form.dispatchEvent(new CustomEvent("ajax-it:requestFailed", { bubbles: true, composed: true })); } })(); } #handleClick(e: MouseEvent) { const anchor = e.target as HTMLAnchorElement; if (anchor.tagName !== "A" || anchor.parentElement !== this) return; e.preventDefault(); anchor.dispatchEvent(new CustomEvent("ajax-it:beforeRequest", { bubbles: true, composed: true })); anchor.dispatchEvent(new CustomEvent("ajax-it:beforeSend", { bubbles: true, composed: true })); (async () => { try { const res = await fetch(anchor.href, { method: "GET", headers: { "Ajax-It": "true", }, }); if (!res.ok) { throw new Error("request failed"); } anchor.dispatchEvent(new CustomEvent("ajax-it:afterRequest", { bubbles: true, composed: true })); const text = await res.text(); this.#injectReplacements(text, new URL(res.url).hash); } catch { anchor.dispatchEvent(new CustomEvent("ajax-it:requestFailed", { bubbles: true, composed: true })); } })(); } #injectReplacements(html: string, hash: string) { setTimeout(() => { const div = document.createElement("div"); div.innerHTML = html; const mainTargetConsumed = !!hash && !!div.querySelector( hash, ); const elements = [...div.querySelectorAll("[id]") ?? []]; for (const element of elements.reverse()) { // If we have a parent that's already going to replace us, don't bother, // it will be dragged in when we replace the ancestor. const parentWithID = element.parentElement?.closest("[id]"); if (parentWithID && document.getElementById(parentWithID.id)) { continue; } document.getElementById(element.id)?.replaceWith(element); } if (mainTargetConsumed) return; if (hash) { document .querySelector(hash) ?.replaceWith(...div.childNodes || []); } }); } } customElements.define("ajax-it", AjaxIt);
You use it like this:
<ajax-it> <form action="/some/url"> <input name=name> </form> </ajax-it>
And that's it! Any elements with an id included in the response will be replaced when the response comes back. It works for elements, too!
The element works two main ways:
So, with some html like this:
<div id=extra-stuff></div> <div id=user-list></div> <ajax-it> <a href="/users/list#put-it-here"> Get users </a> </ajax-it>
and a server response like this:
<ul> <li>user 1 <li>user 2 </ul>
You'll end up with:
<ul> <li>user 1 <li>user 2 </ul>Get users
But if your response had been:
<ul> <li>user 1 <li>user 2 </ul>Hello, I'm out-of-band
you would have ended up with:
Hello, I'm out-of-band
<ul> <li>user 1 <li>user 2 </ul>Get users
...with the id=extra-stuff swapped out-of-band and the
To maintain idempotency, though, I don't tend to use the hash version of things, and just make sure all my response elements have attached IDs:
<ul id=user-list> <li>user 1 <li>user 2 </ul> <p id=extra-stuff>Hello, I'm out-of-band</p>
Which would maintain the