ᕕ( ᐛ )ᕗ Herman's blog

Semantic AJAX-HTML

I recently started fiddling around with HTMX, and I'm pretty impressed. As anyone who's followed the development of Bear knows, I'm pretty sick of the state of modern web development due to the complexity involved in managing the disparity between the front-end and the back-end.

React, Vue, and co. all are trying to solve the problems of the missing middle of the web. As it currently stands, the control logic is split between the client and the server, with some run-between code trying to keep everything synchronised correctly. And this is where HTMX fits in. It's an elegant and powerful solution to the front-end/back-end split, allowing more of the control logic to operate on the back-end while dynamically loading HTML into their respective places on the front-end.

But for a tech-luddite like me, this was still a bit too much. All I really want to do is swap page fragments using something like AJAX while sticking to semantically correct HTML.

During the development of Bear, one of the constraints I created for myself was to do as much as possible using basic HTML components. This means that the only elements that are able to perform a request are <a> and <form>, and these work pretty well if all you're doing is retrieving and serving content synchronously.

But not everything should require a page reload. So here's what I propose (and what I've built as a small demo below):

The target attribute on both forms and anchors is used to target things like new tabs, and also handles the loading of content into iframes. What I am proposing is similar to the iframe spec in many ways, except it applies to all semantic elements.

If a target is specified on a form or an anchor and the target is a query selector, an AJAX request is performed and the target is replaced instead of the default action being triggered.

Anchor example

The following example should get the content of the href using an AJAX call and populate the #my-tab-panel element with the response:

<a href="https://herm.app/cake/" target="#my-panel">Cake</a> |
<a href="https://herm.app/pie/" target="#my-panel">Pie</a>

<div id="my-panel">Nothing here yet...</div> 
Interactive example Cake | Pie
Nothing here yet...

Form example

This example makes a POST request which populates the #form-response element with the response:

<form action="https://herm.app/food/" target="#form-response" method="post">
   <input type="text" name="name" placeholder="Name..." />
   <input type="text" name="food" placeholder="Favourite food..." />
   <br>
   <button>Submit</button>
</form>

<div id="form-response">Nothing here yet...</div>
Interactive example

Nothing here yet...

This makes the page more dynamic, and the form itself can even be replaced by the response HTML.

The JS under the hood

The actual JS is surprisingly simple.

function sendRequest(method, url, data, targetSelector) {
  const options = {
    method: method,
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  };

  if (data) {
    options.body = data;
  }

  fetch(url, options)
    .then(response => {
      if (response.ok) {
        return response.text();
      } else {
        throw new Error('Request failed: ' + response.statusText);
      }
    })
    .then(responseText => {
      const targetElements = document.querySelectorAll(targetSelector);
      targetElements.forEach(targetElement => {
        targetElement.innerHTML = responseText;
      });
    })
    .catch(error => {
      console.error('Request error:', error);
    });
}

document.querySelectorAll('a').forEach(link => {
  link.addEventListener('click', event => {
    if (link.target && !link.target.startsWith('_')) {
      event.preventDefault();
      const targetSelector = link.target;
      sendRequest('GET', link.href, null, targetSelector);
    }
  });
});

document.querySelectorAll('form').forEach(form => {
  form.addEventListener('submit', event => {
    if (!form.target.startsWith('_')) {
      event.preventDefault();
      const targetSelector = form.getAttribute('target');
      const formData = new FormData(form);
      const data = new URLSearchParams(formData).toString();
      sendRequest(form.method.toUpperCase(), form.action, data, targetSelector);
    }
  });
});

Naturally, something like this isn't perfect, but after having used it in a few small projects it feels like a common sense extension to the HTML spec. JS frameworks have their place (especially if you need to handle client-side state), but sometimes something as simple as this is the solution to many basic web applications.

Enjoyed the article? I write about 1-2 a month. Subscribe via email or RSS feed.