1. htmx and htmz
  2. The problem
  3. The solution
  4. A little maze demo
  5. My htmz setup

htmx and htmz

I recently learned a bit of htmx, and I decided I really like it.

tldr: it's a JavaScript framework that lets you do some nifty SPA-like1 things, but without writing any JavaScript yourself; it imposes some harsh limitations, but in exchange you only ever add attributes to HTML. It's kind of like Tailwind, except it's working with the grain of standard web technologies, instead of against.

The htmx people are a class act. They acknowledge they are not a complete solution to every need of the modern web; something like Angular or React, while clunkier, may be needed for some applications. They even advertise competing libraries on their same arena! That's irrelevant to htmx's technical merits, but technical merit isn't the only factor when evaluating a library.

Anyway, that's how I learned about htmz.

htmz is part framework, part statement, part troll. It's a ~300 byte (half that when minified) snippet of HTML that implements the core idea of htmx:

load an HTML response from the server, anywhere you want, without triggering a page reload.

<script>
  function htmz(frame) {
    setTimeout(() =>
      document
        .querySelector(frame.contentWindow.location.hash || null)
        ?.replaceWith(...frame.contentDocument.body.childNodes)
    );
  }
</script>
<iframe hidden name="htmz" onload="window.htmz(this)"></iframe>

You can then target links and forms to the hidden htmz iframe, which will run the htmz function when the response loads.

<a href="/myendpoint#myelemid" target="htmz">link</a>
<form method="POST" action="/myendpoint#myelemid" target="htmz">
  <input type="submit" value="form" />
</form>

The fragment identifier (#whatever), which normally tells the browser where to scroll to on the response, is used to id an element in the current document, which will be replaced with the server's response.

That's the entire framework.

Of course, it's even more limiting than htmx:

  1. Because it lets the browser's normal flow send the requests, it's limited to sending GET and POST HTTP requests, instead of the full range that you get using JavaScript (not really htmz's fault, the HTML spec is just stupid here)
  2. The response you get from the server replaces the targetted element. Always. htmx lets you choose to replace the element, replace its contents, insert before element, insert after element, insert before first child, insert after last child, delete element (insert nothing), or do nothing. htmz is flexibile, but it relies on carefully constructed responses.
  3. No triggering on arbitrary events: htmz only triggers on clicking a link or submitting a form. htmx can trigger on typing, hovering, revealing, etc.
  4. Because it's using fragment identifiers to communicate, and changing the frament does not cause a page reload, two or more links to the same endpoint will conflict.
  5. History gets polluted by default. Every "htmz call" is just a page load, which goes into the browser's history, regardless of whether it makes sense as a standalone page.
  6. Because htmz is only triggered after the response is loaded, we can't add a spinner to tell the user that the request is in progress.

There are a bunch of other things, but these are the ones that seem important.

To me:

  1. Is sad, but not a deal-breaker. I can live with only GET and POST, though I resent the browser vendors for it.
  2. Is barely a limitation. The response is always under our control anyways2.
  3. Is ok. I kind of hate incremental search anyway.
  4. Is occassionally annoying, but I can cope.
  5. Can (and should) be patched out; there's already an extension.
  6. Can (and should) be patched out; there's already an extension.

The problem

There is one thing that really annoys me about the htmx/htmz way of doing things, though: the server needs to know when to send us a HTML snippet instead of a full page.

Imagine we make an endpoint for paginated search; something like GET /search?q=myquery&p=3, which returns the third page of results for a search query. It makes sense that you'll need to serve that both as a standalone page and as a fragment to pull from htmx.

There are a couple of ways to signal to the server what it needs to do:

  1. We can create new endpoints (GET /api/fragments/search?q=myquery&p=3).
  2. We can use content-negotiation (using the Accept header).
  3. We can add our own custom headers (HX-Request).
  4. We can add even more query parameters for GETs, or a hidden field for POSTs.

In their book about using htmx, hypermedia.systems, the official recommendation from the htmx creators is to use the HX-<blah> family of headers, which htmx mostly adds silently in the background.

Every solution here works (though 1 and 4 misbehave when treated as links), but they all make the server code considerably more complicated. If your backend only serves static files, then 3 and 4 don't work. Option 1 (creating new endpoints to serve fragments) is the most straightforward and robust, but it still complicates the server code.

The solution I like, which hypermedia.systems kinda pooh-poohs as inefficient, is to let the server always return a complete page and have the client extract from it the part that it needs.

htmx supports this using the hx-select attribute, and I figured I could implement it as an htmz extension.

The solution

Here's the extension code:

<script>
  function htmz(frame) {
    // select start
    const fragments = decodeURIComponent(frame.contentWindow.location.hash).split(" ");
    frame.contentWindow.location.hash = fragments[0];
    const q = fragments.slice(1).join(" "); // `li, .listitem` is valid

    if (q) {
      frame.contentDocument.body.replaceChildren(
        ...frame.contentDocument.querySelectorAll(q || null)
      );
    }
    // select end

    setTimeout(() => {
      document
        .querySelector(frame.contentWindow.location.hash || null)
        ?.replaceWith(...frame.contentDocument.body.childNodes);
    });
  }
</script>
<iframe hidden name="htmz" onload="window.htmz(this)"></iframe>

htmz usage stays the same, this works as it should:

<a href="/myendpoint#myelemid" target="htmz">link</a>

But now we can extract parts of the response using arbitrary CSS selectors:

<!-- pull every element with class "myclass" and the "active" attribute -->
<a href="/myendpoint#myelemid .myclass[active]" target="htmz">link</a>
<!-- pull every figure and the element with id "content" -->
<a href="/myendpoint#myelemid figure, #content" target="htmz">link</a>

A little maze demo

I made a silly little demo of the idea, consisting of a tiny maze you can run around in, choose-your-own-adventure style.

Each location in the maze is implemented as a standalone HTML page. You can easily share a link to a specific location in the maze, or bookmark it to save your progress; you can check this by opening a location in a separate tab.

My htmz setup

<script>
  function htmz(frame, history=false) {
    // no-history start
    if (frame.contentWindow.location.href === "about:blank") {
      return;
    }
    // no-history end

    // loader start
    frame.contentWindow.addEventListener("unload", () => {
      setTimeout(() => {
        document
          .querySelector(frame.contentWindow.location.hash || null)
          ?.classList.add("loader");
      });
    });
    // loader end

    // select start
    const fragments = decodeURIComponent(frame.contentWindow.location.hash).split(" ");
    frame.contentWindow.location.hash = fragments[0];
    const q = fragments.slice(1).join(" ");
    if (q) {
      frame.contentDocument.body.replaceChildren(
        ...frame.contentDocument.querySelectorAll(q || null)
      );
    }
    // select end

    setTimeout(() => {
      document
        .querySelector(frame.contentWindow.location.hash || null)
        ?.replaceWith(...frame.contentDocument.body.childNodes);

      // no-history start
      if (!history) {
        frame.remove();
        document.body.appendChild(frame);
      }
      // no-history end
    });
  }
</script>
<iframe hidden name="htmz" onload="window.htmz(this)"></iframe>
<iframe hidden name="htmz-history" onload="window.htmz(this, true)"></iframe>

Select queries are enabled, the target element gets the loader class while the request is in flight, and history is disabled by default. To get a page added to the history, change the target attribute from htmz to htmz-history, like so:

<!-- request doesn't affect history -->
<form method="get" action="/search#searchresult" target="htmz">
  <input type="text" name="q" />
  <input type="submit" />
</form>
<!-- request affects history -->
<form method="get" action="/search#searchresult" target="htmz-history">
  <input type="text" name="q" />
  <input type="submit" />
</form>