< Developing an HTMX Alternative

Written: 2024-01-13

# Introduction

If you're not aware of htmx, it's a small library which provides a lot of convienient functionality to a website. Some of this functionality is the ability to allow any element in the HTML DOM, to fire a request to an endpoint, without having to be an <a> tag (link) or a submission <form>. This allows you to do something to add triggers to anything from a paragraph to a button, which when clicked on, hovered over or whatever, to fire an event. htmx allows for this to be possible, to interact with the page content very conveniently.

Let's take an example. Imagine you have a page of videos, at the bottom of the page you have a button called "View More". When clicking this button, you may want more content to be loaded. htmx makes this very easy:

<div id="videos">
    <!-- videos here -->
</div>
<button hx-get="/content" hx-target="#videos" hx-swap="beforeend">View More</button>

When you click the "View More" button, it will send a GET request to the endpoint /content, when we receive the response from the endpoint, it will insert the content to the "videos" div, before the end of the closing </div>. So with just a few HTML attributes, we have now added dynamic content loading to our video page.

Typically when submitting a form with a post request, it'll redirect unless you create some JavaScript code to handle it for you. But with htmx, this isn't necessary.

<p id="result"></p>
<form hx-post="/submit" hx-target="#result">
    <input type="text" username="name">
    <input type="submit">
</form>

When you submit the form, it will send a POST request to the endpoint /submit, without redirecting, then it'll replace our paragraph <p></p> with the result of the POST request, which is whatever the server responds with.

While functionality like this is commonly handled with JSON responses by the server, this is not what we want when using htmx. Here we expect the server to return some HTML, which can be inserted into the DOM neatly and quickly.

# Reasoning

So why am I making an alternative? Because performance and size matters a lot to me. While htmx provides very convienient functionality, it also contains a lot of stuff that I simply do not need. It contains node trigger inheritance, where each child of a node also behaves on the event. It provides CSS transitions, websockets, animations and much more.

But how big is htmx? Well let me tell you, the minified source code is ~40 KB (157 KB non-minified), which to many won't seem like a lot. But I think there's something worth considering. The more JavaScript code it contains, the more resources will be consumed and people with slower internet connections will have to wait longer, in turn generating less traffic.

Having now created several websites with a perfect performance score of 100 on Google PageSpeed, it doesn't take a lot for the performance score of a webpage to decrease.

So I decided to create a smaller htmx(ish) alternative, which allows for similar functionality to the code examples above. Here's how I did it.

# Design

First I knew that I wanted to specify the request type with an HTML attribute. htmx uses hx-get, hx-post and so on, for the different request types. This seems a bit wasteful, why not specify the request type in an attribute value? So I thought I'd just combine it into the attribute hxm-req, where you can specify the request type like hxm-req="get".

Since htmx specifies the url with the request type attribute, like hx-get="/endpoint", I'd now have to think of something else with the change mentioned above. For this I simply decided on hxm-url="/endpoint".

htmx allows for quite a number of different swap modes. These are the following modes:

I'd like to support all of these as well.

For selecting the target to perform the swap action on, htmx just uses a query selector like #id or input[type="text"]. This is a very useful and simple way to perform the swap on specific elements, so I'll do the same.

Last but not least we have the trigger. Should elements be triggered on click events? What if I want to trigger them on hover instead? I'll allow the user to specify their own trigger, as can be done with htmx. I'll also make use of their defaults.

Now I have a general idea of how it should look, but now I need to implement it. For this I had to consider some things as well. Would I make use of fetch or old-school Ajax requests using XMLHttpRequest? For the widest range of browser support, I decided to go with XMLHttpRequest. There may be more to figure out, but I decided to proceed for now.

# Development

First I'll add an event listener for DOMContentLoaded to the document. In here I could just grab all elements that contain [data-hxm-req], so we force the request type to be specified.

Now let's loop over all of the elements and check if their request type is valid... hmm... maybe I should create a list of these valid types first?

let requestType = ['get', 'post', 'put', 'delete', 'patch'];
document.addEventListener('DOMContentLoaded', () => {
  let elements = document.querySelectorAll('[data-hxm-req]');
  for (let i = 0; i < elements.length; i++) {
    let element = elements[i];

    let type = element.getAttribute('data-hxm-req');
    if (!requestType.includes(type.toLowerCase())) {
      throw new Error(`Invalid type: ${type}. It has to be ${requestType.join(', ')}`);
    }

Alright, now that we can check if the request type is valid, we can move on to the other attributes.

let url = element.getAttribute('data-hxm-url');
let trigger = element.getAttribute('data-hxm-trigger');
let target = element.getAttribute('data-hxm-target');
let swap = element.getAttribute('data-hxm-swap');

I should check if these are set, if they aren't, I should give them a default value.

if (!url) {
  url = window.location.href;
}

if (!trigger) {
  switch (element.tagName.toLowerCase()) {
    case 'input':
    case 'textarea':
    case 'select':
      trigger = 'change';
      break;
    case 'form':
      trigger = 'submit';
      break;
    default:
      trigger = 'click';
  }
}

if (!swap) {
  swap = 'innerHTML';
}

There are a couple of things going on here. If no url/endpoint was specified, then we'd set the default to the endpoint of the current page. So if you're on the index page, then the url would be /.

For the trigger, we assume that the user inputted a valid trigger if there is one. If one hasn't been specified, we set the default trigger based on the element tag name.

At the end, if no swap mode is specified, then we set the default to innerHTML. But it'd be nice if there was some error handling here, to make sure that the user didn't specify an invalid swap mode.

let swapMode = ['innerHTML', 'outerHTML', 'beforebegin', 'afterbegin', 'beforeend', 'afterend', 'delete', 'none'];

document.addEventListener('DOMContentLoaded', () => {
  // ...
  if (!swap) {
    swap = 'innerHTML';
  }

  if (!swapMode.includes(swap)) {
    throw new Error(`Invalid swap mode: ${swap}. It has to be one of ${swapMode.join(', ')}`);
  }

Since we're looping over all the elements to handle, we cannot use the variables in the loop scope, as they wouldn't be saved in the event listener we'd couple to each element. Therefore we need a way to access the variables, in each event listener. To do this we create an elementMap = {}, were we simply add each element property like so:

let elementMap = {};
// ...

document.addEventListener('DOMContentLoaded', () => {
  let elements = document.querySelectorAll('[data-hxm-req]');
  for (let i = 0; i < elements.length; i++) {
    let element = elements[i];

    // ...

    elementMap[element] = { url, type, target, swap };

As we don't need to save the event trigger name, we don't have to add it to the elementMap. Now we can start adding the event listener for each element.

element.addEventListener(trigger, (event) => {
  event.preventDefault();

  const el = elementMap[event.target];

Now we're at the fun part, the XMLHttpRequest.

let xhr = new XMLHttpRequest();
xhr.open(el.type, el.url, true);
xhr.onreadystatechange = () => {
}

xhr.send();

We specify that the request should run asynchronously, with the true value in xhr.open(), and now we're sending the request! But there's quite a big part missing. Currently we're not using the response for anything. First we should check that the response is successful.

xhr.onreadystatechange = () => {
  if (xhr.readyState == 4 && xhr.status == 200) {

  }
}

Now there's a couple of things we should do. Firstly, we know that if swap mode is 'none', we may as well return early. While we're at it, we may as well get the response text and targets to swap.

if (xhr.readyState == 4 && xhr.status == 200) {
  if (el.swap == 'none') { return; }

  let response = xhr.responseText;
  let targets = el.target ? document.querySelectorAll(el.target) : [event.target];
}

As you may have noticed earlier when we were verifying that the attribute values were valid, we never had a check for the target. This was done consciously, because let's say that the DOM has changed before the event listener was called. Therefore it makes more sense to get all of the targets, in the event listener. If el.target is something, we set targets equal to the NodeList that document.querySelectorAll() returns. Else if no target was specified, we set the targets to be an array of the element itself. As is the default with htmx.

Now we can loop through all of the targets, and perform the swap on them.

for (let i = 0; i < targets.length; i++) {
  if (el.swap == 'delete') {
    targets[i].parentNode.removeChild(targets[i]);
  }

  targets[i][el.swap] = response;
}

Now what is going on here? At first I was going to use targets[i].delete(), but a quick look at Can I Use tells me that's not available in older browsers. So I opted for the old parentNode.removeChild(node).

targets[i][el.swap] looks quite weird, why did I write it like this? Well since properties can be accessed with both node.property and node['property'], we can use this to our advantage, to perform the desired swap mode, like targets[i]['innerHTML'] = response;

One thing I was not aware of when I was developing this, was that beforebegin, afterbegin, etc. could not be accessed as a property. So currently only innerHTML and outerHTML works. To use the other modes we have to use insertAdjacentHTML. To do this optimally I create a little variable before the loop. And it now looks like this:

let adjacent = (el.swap != 'innerHTML' && el.swap != 'outerHTML');
for (let i = 0; i < targets.length; i++) {
  // ...

  if (adjacent) {
    targets[i].insertAdjacentHTML(el.swap, response);
  } else {
    targets[i][el.swap] = response;
  }
}

Our code now works!.. almost. How about <form> elements? Sure we can send a request now using our module, but the form data is never included in the request. Luckily JavaScript makes this very easy!

let xhr = new XMLHttpRequest();
xhr.open(el.type, el.url, true);
xhr.onreadystatechange = () => {
  // ...
}

let data = {};
if (event.target.tagName.toLowerCase() === 'form') {
  data = new FormData(event.target);
}

xhr.send(data);

By passing in a <form> element to the FormData constructor, we can now include the data in the request.

And there we go! We can now create something like the following:

Showcase

The HTML for the jiff 🥵 above looks like the following:

<h1>Hello World!</h1>
<button data-hxm-req="get" data-hxm-url="/content">
    Get content
</button>

<fieldset>
    <legend><b>Form</b></legend>

    <p>Type your information:</p>
    <form data-hxm-req="post" data-hxm-target="p" data-hxm-swap="beforeend">
        <label>Name <input type="text" name="name" /></label>
        <input type="submit" />
    </form>
</fieldset>

# Results

So what are the results?
Well compared to the 3905 line htmx JavaScript file, this one is just 85 lines.
htmx is 159 KB (~40 KB minified). This one is ~3 KB unminified.

You can find the source code for this project on GitHub [link]