A few months with htmx

Looking for a better way to manage your org's technical proposals?

I've been working on a project to manage technical proposal docs for several years. That said it wasn't until a few months ago that I finally registered a domain and rebuilt the UI entirely with htmx. This is the first time that I've used htmx and after a few months I've developed some thoughts about it. Check it out here if you're interested:

RFC Hub

How I've Built Webapps Before

I'm adding this section just for some context around my opinions. I've built many webapps over the past two decades, beginning with JavaScript-free PHP projects, then adding a bunch of browser-side JavaScript and calling it AJAX, before moving to using Node.js on the backend. My first book was about Backbone.js, I wrote some MooTools code that might have once run in your car's entertainment center, and I've published Single Page Application (SPA) mobile games to the Android, iOS, and FirefoxOS stores.

The last application that I built was Map Buddy. Map Buddy is a mobile Progressive Web App (PWA), published to the Apple and Android stores. It is built using Vue.js and mostly revolves around interactive maps and a highly interactive UI. The goal with this webapp was to feel entirely like a native mobile app and it succeeded. The API is completely documented and is exposed as JSON over HTTP.

Suffice to say I've built every kind of webapp.

RFC Hub Requirements

At the end of the day RFC Hub is mostly a CRUD application. People make an account, make an organization, add RFCs to their organization, add content using Markdown, and view the content rendered as HTML. People can create tags at the org level, tag their RFCs with those tags, link RFCs together, leave comments, dismiss comments, update content, change review status, and mark RFCs as being published.

All CRUD applications can be represented as a JavaScript-free website. However, clicking around and triggering entire page loads won't always lead to the quickest and most intuitive experience. Sometimes we want a page to display two or more components with state that needs to be maintained independently. For that reason JavaScript can improve the experience. As an example on RFC Hub, the RFC editor screen has a syntax-highlighted markdown editor and a file upload dialog. Files can be uploaded without interrupting the text-editing experience. Juggling two stateful components like this without JavaScript is something you can't do cleanly without JavaScript.

I also want RFC Hub to feel fast. The industry standard is that you need to use client-side rendering, build an SPA, and display loading animations while content is being fetched. But I've found that keeping network payloads small and intentional and embracing the browser's cache leads to a fast experience as well. For example, when it comes to adding a comment to an RFC, I want a user that has scrolled to an arbitrary location in the page to type in a comment, submit it, and not have the scroll position change.

Positive Experiences with htmx

Overall, working on RFC Hub has been a refreshing experience that harkens back to the simpler days of building PHP apps. There is no build step, no magic. Dynamic code that runs in the browser is about 90% htmx and 10% vanilla JavaScript. HTML is almost entirely rendered on the backend using EJS templates.

Browser libraries are simply downloaded and checked-in to the codebase (I loath mixing frontend and backend packages in a single package.json, which is how Map Buddy works). I am confident that the RFC Hub codebase will still run in 10 years with minimal changes, whereas Map Buddy ran into outdated and incompatible npm packages constantly (even though it has less than 20 dependencies). The app-specific browser JavaScript weighs in at 200 lines and is mostly used for the RFC view screen for comment rendering.

Working with htmx is nice because it's like defining the interactions of an application in a declarative way. Here's an example of some code from the RFC Reviewer screen for adding a new reviewer to an RFC:

<tr>
  <td>Thomas Hunter II</td>
  <td>
    <form id="row-123" hx-post="/rfchub/rfc99/reviewers">
      <input type="checkbox" name="required" /> Required?
      <input type="hidden" name="reviewer_id" value="123" />
    </form>
  </td>
  <td><input form="row-123" type="submit" value="Add Reviewer" /></td>
</tr>

This is essentially a table row that is wrapped in a form element, except that you can't do that in HTML, and so to have form elements in one table cell and a button in another cell one must use that <input form="foo"> attribute. But that's not an htmx thing.

The part that is htmx is the <form hx-post="/rfchub/rfc99/reviewers"> attribute. Essentially it is the equivalent to <form method="post" action="/rfchub/rfc99/reviewers"> except that it won't do a full page load and instead make a fetch request and then does something with the response (more on that in the negative experiences section). That said I do wish I could do <form hx method="post" action="/rfchub/rfc99/reviewers"> instead and get graceful fallback…

htmx isn't just limited to <form> elements. Here's another example from RFC Hub for deleting a reviewer:

<a hx-delete="/rfchub/rfc99/reviewers/123" hx-confirm="Are you sure?">Remove</a>

Upon clicking the above anchor the browser displays a confirmation prompt. If the cancel button is clicked then the prompt is dismissed and nothing happens. If the OK button is clicked then the browser makes an HTTP DELETE request using fetch and then does something with the response.

Here's another neat example from the status change screen. This is the button that, once clicked, publishes the RFC:

<button
  hx-post="/rfchub/rfc99/status"
  hx-vals='{"status":"published"}'
  hx-confirm="Are you sure? An RFC should only be published once it no longer needs changes."
  >Publish</button>

The key/value pairs inside of the hx-vals JSON attribute translate into key/value pairs that are sent as part of the request. The same endpoint is used for all status changes so hx-vals makes it easy to send different values along.

It does sort of feel like we lost some knowledge of how powerful webpages can be without heavy frameworks. Think back to adding a comment when scrolled in the middle of an RFC. Normally this would seem to require updating the DOM and not doing a page reload to retain scroll position. However, it turns out that if you refresh the page (e.g. location.reload()), and if the HTML hasn't changed too much, the scroll position is retained perfectly. It feels like a fetch and a DOM update since it all happens so quickly and smoothly. But under the hood it's a page refresh and a bunch of cached assets being loaded.

Note that calling location.reload() on a complex SPA like Facebook won't work; the HTML arrives too late for that. And if the page has a bunch of janky scripts loaded into it such as advertisements, or lazy image loaders, then the scroll position is likely to jump around a bit as well.

Negative Experiences with htmx

My main qualm with htmx is that too much of the decision-making happens via HTTP response headers when I would much prefer to define everything in the UI where an action is defined.

By default, the HTML that is returned from an htmx request replaces the element that has the hx-* field on it. I often want to either refresh the whole page or redirect to a new page. Knowing that I want to refresh or redirect is something that I know when I'm creating the form; it's a UI concept. I want to define it using some sort of hx-* field.

Instead, htmx requires that this is specified by replying with HX-Refresh or HX-Redirect headers. This is annoying as two different locations in the interface could call the same endpoint and I don't want that endpoint to have to worry about which part of the interface is calling it. As a concrete example, you can add an arbitrary reviewer to an RFC from the RFC reviewer screen, or you can add yourself quickly from the RFC view screen. Adding from the RFC reviewer screen should swap out the reviewers list (or refresh the page), and adding from the RFC view screen should add your name and add a leave button (or refresh the page).

I basically need to know if any given endpoint is for a normal browser request or an htmx request and juggle helper functions that deal with the headers. That's not pretty and doesn't scale.

Another issue I have with htmx is that by default when a 5XX server error happens nothing is displayed on the screen. No swap happens, no redirect happens. The user simply clicks a button or submits a form and then sees no changes. I suppose that defining a default behavior that works for every situation is difficult and that's why htmx chooses to fail silently.

Final Thoughts

Overall I feel that htmx is a nice library and will get RFC Hub through the first couple years of development. However, if it ever gets to the point where there is a team working on it, or if the amount of complex state displayed in any given screen grows too much, then the project will likely need to be migrated to a new framework.

If you want to try an app that's mostly built with server side rendering a smattering of htmx then make an account on RFC Hub and make an RFC to experiment with.

Thomas Hunter II Avatar

Thomas has contributed to dozens of enterprise Node.js services and has worked for a company dedicated to securing Node.js. He has spoken at several conferences on Node.js and JavaScript and is an O'Reilly published author.