(Image credit: https://www.maicar.com/GML/Ajax1.html)
I recently posted 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 replaced 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 <a>
elements, too!
The element works two main ways:
- If your
action
orhref
includes a hash, the element on or current page with an id matching that hash will be replaced with the contents of the entire response. - If your returned html contains elements that themselves have IDs, and those IDs have matches in the current document, those elements will be replaced first (and excluded from the “whole response” replacement above). This is essentially how you do “out of band” swaps (aka
hx-swap-oob
).
So, with some html like this:
<div id=extra-stuff></div>
<div id=user-list></div>
<ajax-it>
<a href="/users/list#user-list">
Get users
</a>
</ajax-it>
and a server response like this:
<ul>
<li>user 1
<li>user 2
</ul>
You'll end up with:
<div id=extra-stuff></div>
<ul>
<li>user 1
<li>user 2
</ul>
<ajax-it>
<a href="/users/list#user-list">
Get users
</a>
</ajax-it>
But if your response had been:
<ul>
<li>user 1
<li>user 2
</ul>
<p id=extra-stuff>Hello, I'm out-of-band</p>
you would have ended up with:
<p id=extra-stuff>Hello, I'm out-of-band</p>
<ul>
<li>user 1
<li>user 2
</ul>
<ajax-it>
<a href="/users/list#user-list">
Get users
</a>
</ajax-it>
...with the id=extra-stuff
swapped out-of-band and the <ul>
swapped normally.
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 <ul>
id and make clicking on the <a>
repeatedly idempotent (as one would expect). In this case, your href
can just be href="/users/list"
.
It's also fully progressively enhanced: as long as your action
attribute points to a regular endpoint, things will behave as expected if JS isn't working or fails to load. All you have to look for on the server side is an Ajax-It: true
header, so you can respond with minimal html instead of a full response.
Huge kudos and credit to htmz, which this is largely based on, except I needed to do it with AJAX instead of the iframe trick because I actually needed lifecycle events to do some of the offline trickery I'm doing.
Anyway cheers. Feel free to use the element in your own stuff! Consider it public domain :)
Top comments (3)
Hey Kat, firstly it’s good that you are always searching for a better/easier way of doing things. What you’ve got here is a very simple component that solves some very complex challenges. Nice read and thanks 😊.
Looking at the
handleSubmit
function I am somewhat puzzled; why is only the latter half of the function wrapped in an async iife?Is there any specific advantage over just making the entire method
async
instead? If memory serves me right, evenasync
functions should run synchronously until their firstawait
as a core JavaScript thing, but maybe I'm just missing some nuance.I think I got confused by some unexpected behavior while originally authoring it and thought things would always be async. The whole thing can probably be async. I’ll change it