This content originally appeared on DEV Community and was authored by Kat Marchán
(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 <a>
elements, too!
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 🙂
This content originally appeared on DEV Community and was authored by Kat Marchán