Reusable htmx components with custom elements

While working on my chat bot project, I was recently looking for a way to test locally ony my laptop before pushing to my phone. To make inroads quickly, I settled on a local web app. But neither manual dom manipulation nor a full scale single page app felt particularly appealing. Luckily, htmx offers a middle ground - as it turns out a quite productive one.

In this post I would like to showcase how custom elements allow to build reusable components that encapsulate the htmx configuration and the handling of htmx events.

htmx aims to extend the HTML model by enabling attribute driven requests that can update parts of the page without full page reloads. For example, the following HTML

<button hx-post="/message" hx-target="#message">
    Click Me!
</button>
<div id="message"></div>

gives you a button, that when clicked performs a post request against /message and places the response into the message div. Given the response <b>Hello World</b>, the message div afterwards will read

<div id="message"><b>Hello world</b></div>

My initial implementation used htmx annotations exclusively with rapid results and worked beautifully. However, the way I replaced all JavaScript with htmx led to a tangled mix of server-side and client-side logic.

For example, the chat input looked like

<form 
    id="user-input" 
    hx-post="/message" hx-swap="afterbegin" hx-target="#messages"
>
    <input id="user-message"  name="user-message"  type="text"/>
</form>

Following common interface conventions, I wanted the input to clear after sending the message. This feature can be implemented using htmx's out of band updates. The idea is to replace the message box by sending with the response an additional update for the input. In our example, the update will include an additional

 <input 
    id="user-message" name="user-message" type="text" 
    hx-swap-oob="true"
/>

But why should the server be tasked with a purely client-side concern?

Another option is to use the rich set of events htmx triggers. Using these events, the input can be cleared by resetting the form for every htmx:beforeSend event. Simply include a script tag that reads

<script>
document
    .getElementById("user-input")
    .addEventListener("htmx:beforeSend", ({target}) => target.reset())
</script>

While using this method works, it becomes quickly unwieldy if there are more elements to configure or elements are generated dynamically, for example by htmx.

In addition, htmx annotations can become quite verbose if there are multiple elements that require the same configuration. For example, my chat bot supports the equivalent of Telegram's inline keyboards. Using only htmx means adding three additional attributes to each button.

One way to address both of these concerns are custom elements. Custom elements allow to define completely new HTML elements or to extend existing elements.

Here, I opted for extension as the resulting HTML is meaningful even without JavaScript enabled. In our example, the chat input form uses a custom extension element chat-input by including the is attribute, as in

<form id="user-input" is="chat-input">
    <input name="user-message" type="text"/>
</form>

The behavior of the chat-input extension is defined using the browser's JavaScript API with

<script>
customElements.define(
    "chat-input",
    class extends HTMLFormElement {
        connectedCallback() {
            if(!this.isConnected) return;
            
            this.dataset.hxPost = "/message";
            this.dataset.hxTarget = "#messages";
            this.dataset.hxSwap = "afterbegin";
            
            this.addEventListener("htmx:beforeSend", () => this.reset());
        }
    },
    { extends: 'form' },
);
</script>

This extension element configures htmx by assigning to dataset and sets up the event handlers. One surprise to me was that there is no need to call htmx.process on the element. htmx configures it automatically. In the full implementation, I use the same technique to define custom elements for the inline-keyboards and for a message display that only keeps the last 30 messages.

While this approach requires writing additional JavaScript, it results in very clean HTML. At the moment I only used this setup for the local chat app, but with my sights already set on a bigger project, I am quite interested to see whether this idea scales. A fully worked example of a chat app is hosted here. To see the code, simply use "right click > show source". The "server" is a four line php script that waits for half a second and then echos the input message. As always, feel free to reach out to me on twitter @c_prohm with feedback or comments.


The currentScript property allows to implement a fun little hack I used in my implementation of event handlers that sets event listeners on the surrounding element without IDs or CSS queries. First, define the $on helper to be

<script>
    const $on = (...args) => {
        document.currentScript.parentElement.addEventListener(...args)
    }
</script>

and then use it as in

<form 
    id="user-input" 
    hx-post="/message" 
    hx-swap="afterbegin" 
    hx-target="#messages"
>
    <script>$on("htmx:beforeSend", ({target}) => target.reset())</script>
</form>