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>