DEV Community

Cover image for I replaced htmx with a simple web component
Kat Marchán
Kat Marchán

Posted on

I replaced htmx with a simple web component

(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);
Enter fullscreen mode Exit fullscreen mode

You use it like this:

<ajax-it>
  <form action="/some/url">
    <input name=name>
  </form>
</ajax-it>
Enter fullscreen mode Exit fullscreen mode

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:

  1. If your action or href 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.
  2. 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>
Enter fullscreen mode Exit fullscreen mode

and a server response like this:

<ul>
  <li>user 1
  <li>user 2
</ul>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

...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>
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
cyberb0rg profile image
Joe • Edited

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 😊.

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

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, even async functions should run synchronously until their first await as a core JavaScript thing, but maybe I'm just missing some nuance.

Collapse
 
zkat profile image
Kat Marchán

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