A few days ago, I completed and published a demonstration of a DataTable component built with HTMX and AlpineJS. This article describes the approach I implemented and explains some patterns used for the development of this demo.
Read this article in french on my website
Contents
The Project
The goal of this project is to evaluate the relevance of HTMX in various use cases, from content-oriented sites to complex management applications. I’m trying to prove (to myself and others) that HTMX and the hypermedia application approach is not limited to simple applications or basic user experiences.
To do this, I’ve initiated a project aimed at demonstrating various patterns frequently encountered in modern applications, developing them with HTMX and the ecosystem I refer to as "hypermedia application".
You can interact with the demo at this address: https://link.benoitaverty.com/data-table-htmx
Technical Stack
- HTMX: The library that makes this approach possible. HTMX allows the development of applications with HTML and HTTP, but by lifting a whole set of limitations, particularly by allowing interaction with the server without necessarily reloading the entire page.
- AlpineJS: An enhanced user experience still requires some degree of client-side interactivity. AlpineJS provides this interactivity while remaining within the hypermedia application paradigm. The interactivity is still coded directly in the HTML markup, without writing JS (or almost none). Where HTMX focuses on client/server interactions, AlpineJS allows adding some client-side behavior.
- Kotlin / Ktor: Ktor is a Kotlin framework that allows developing HTTP APIs. It’s somewhat equivalent to Express or Fastify in Kotlin. One of the advantages of the Hypermedia approach is that it allows development in a language other than Javascript, and I took this opportunity for these demos.
Together, these three components form the equivalent of the AHA stack (but with Ktor instead of Astro), and the site explains very well the approach used for this project.
It should also be noted that the use of AlpineJS remains moderate: the interactivity provided by Alpine is, in my opinion, less maintainable than with a frontend framework like VueJS or React. Alpine is interesting because it is extremely complementary to HTMX, but I use it sparingly, and only to improve a UX defect that would be too significant with pure HTMX.
What This Article Is Not
This article is not an argument for or against adopting HTMX and the hypermedia approach. I aim to provide material to help make this choice by showing concretely what is possible, and by dismantling certain preconceived ideas.
This article does not show the server-side code used to generate the HTML "enhanced" by HTMX. I will show the final HTML/HTMX code, and you could generate it with any server technology to obtain the same user experience.
The Demo
This demo shows a DataGrid
such as can be found in most management applications. It allows interaction with a collection of objects stored in a database or other source. In this case, it’s a list of objects that could be found in an inventory, with quantity in stock, supplier, category, etc.
The list allows you to:
- Sort, filter, and access data pages
- Delete elements
- Create a new element
The result obtained by manipulating these different criteria has a URL that can be shared to find the list in the same state.
All functionalities work if JavaScript is disabled or if one of the scripts (htmx or alpine) fails during downloading or execution.
All features are accessible by keyboard.
General Structure
External resources are limited to HTMX (and a plugin), AlpineJS (and two plugins), and CSS. In total, less than 50kb (minified/compressed).
<html>
<head>
<link href="/app.css" rel="stylesheet" type="text/css">
<link href="/pico.min.css" rel="stylesheet" type="text/css">
</head>
<body>
<!-- The document -->
<script src="/htmx.js"></script>
<script src="/alpinemorph.min.js"></script>
<script src="/alpinefocus.min.js"></script>
<script src="/alpinejs.min.js"></script>
<script src="/htmx-ext-alpine-morph.js"></script>
</body>
</html>
The main element of the page is of course an HTML table with its headers:
<table class="data-table">
<thead>
<tr>
<th>
<button type="submit" class="link-btn" form="inventory-controller" name="nextSort" value="name" role="link"
key="sort-by-name">Name↓↑
</button>
</th>
<th>Description</th>
<th>
<button type="submit" class="link-btn" form="inventory-controller" name="nextSort" value="quantity"
role="link" key="sort-by-quantity">Qty.↓↑
</button>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<!-- ... -->
<tr class="data-row" key="21b8915b-5667-4cb8-bfa3-aef5187eccab">
<td class="data-cell">
<div>
<a href="/data-table/21b8915b-5667-4cb8-bfa3-aef5187eccab"
hx-get="/data-table/21b8915b-5667-4cb8-bfa3-aef5187eccab" hx-target=".item-details-placeholder"
hx-swap="innerHTML" hx-push-url="true">Wireless Mouse</a>
</div>
</td>
<td class="data-cell">
<div>A sleek and ergonomic wireless mouse</div>
</td>
<td class="data-cell">
<div>25</div>
</td>
<td class="data-cell">
<div>
<form action="/data-table/21b8915b-5667-4cb8-bfa3-aef5187eccab/delete" method="post"
hx-delete="/data-table/21b8915b-5667-4cb8-bfa3-aef5187eccab" hx-target="closest tr"
hx-swap="outerHTML swap:300ms">
<button type="submit" class="danger delete-btn" aria-label="Delete">✘</button>
</form>
</div>
</td>
</tr>
<!-- Etc. -->
</tbody>
</table>
This table displays the data in a completely classic way, but already contains a number of HTMX attributes and HTML elements that deserve explanation.
Deleting an Element
The last column of the table contains the following button form:
<form
method="post" action="/data-table/21b8915b-5667-4cb8-bfa3-aef5187eccab/delete"
hx-delete="/data-table/21b8915b-5667-4cb8-bfa3-aef5187eccab"
hx-target="closest tr"
hx-swap="outerHTML swap:300ms"
>
<button type="submit" class="danger delete-btn" aria-label="Delete">✘</button>
</form>
The three hx-*
attributes are sufficient to code the deletion behavior.
-
hx-delete
: when the form is submitted, aDELETE
request is sent to the server at the specified URL. The server handles deleting the item in the database and returns an empty response with status code 200. -
hx-target="closest tr"
: the firsttr
element in the ancestors of theform
element will be replaced at the end of the request -
hx-swap
:outerHTML
specifies that the entiretr
element is replaced (by default, it would be only its content), andswap:300ms
means that HTMX will apply anhtmx-swapping
class to the element for 300 milliseconds before replacement.
Since the server response is empty, the table row is simply deleted, which is sufficient for our deletion feature.
Why swap:300ms
?
Modern UX relies heavily on animations. In addition to offering a more pleasant look & feel, they provide a form of feedback and allow users to better observe the result of their action.
The HTMX mechanism activated by swap:300ms
allows adding an animation to our deletion using CSS transitions1.
.data-cell > div {
/* ... */
transition: all 300ms ease;
height: 4.5rem;
overflow: hidden;
}
tr.htmx-swapping .data-cell > div {
height: 0;
padding-bottom: 0;
padding-top: 0;
overflow: hidden;
}
Why use a form and a button of type submit
?
HTMX allows making requests from any element. So we could have done without the form and used a simple button
with the same three hx-*
attributes.
Using the form allows the application to function even if JavaScript is disabled or if the htmx.js
script fails to execute for some reason. If HTMX is not loaded, the browser will handle making an HTTP request itself thanks to the method
and action
attributes. In this case, the server will also perform the deletion and redirect the user to the URL where they were. There will be no animation and the entire page will be reloaded, but the deletion works perfectly. This is an example of progressive enhancement made relatively simple to code thanks to the hypermedia application paradigm.
Pagination / Sorting / Filtering
These three functionalities are similar, as they allow controlling which elements are displayed in the table. Additionally, they must work together (changing pages should not reset the filter, for example). Finally, these functionalities will change the URL of the current page to allow the user to bookmark or share any state of the table.
Server-side, it’s relatively simple: a request to the URL /data-table
accepts query params that control the displayed elements (sort
, page
, pageSize
, search
). Additionally, the server detects if a request was made by HTMX using the HX-Request
header and only returns the table (not the entire document) if a request is made by HTMX.
Client-side, we need to use attributes that will trigger a GET
request with these parameters. There is a native HTML element that allows storing a state and triggering requests from this state, which is the form. So we will trigger our requests from a form.
<form
action="" method="get"
id="inventory-controller"
hx-trigger="submit, input from:[form=inventory-controller] delay:100ms"
hx-get="/data-table"
hx-target="#inventory"
hx-push-url="true">
</form>
-
hx-trigger
allows more detailed specification of events that trigger a request. By default, forms trigger a request on thesubmit
event. Here, we also trigger the request if an input element belonging to this form changes. This is what allows automatically reacting when a text query is typed or when changing pages.delay:100ms
helps avoid sending too many requests when a user quickly types text in the search field. -
hx-get="/data-table"
specifies the verb and URL of the request made by HTMX -
hx-target
: When the server receives a request at this URL, it returns the HTML table corresponding to the criteria. HTMX will insert it into the page in place of the element corresponding to the selector#inventory
-
hx-push-url
allows specifying that the page URL should be updated with the request URL. Here, it allows adding the query params corresponding to the form fields to the page URL. This way, the user can refresh their page or share it without losing the state of the table.
At this point, I haven’t yet mentioned the form fields. Form fields (elements input
, button
, select
, etc.) don’t necessarily need to be descendants of the form
element. The fields are therefore at the appropriate place in the rest of the document, linked to the form through their form
attribute
<!-- Page change -->
<select form="inventory-controller" name="page">
<option value="1">1</option>
<option value="2" selected="selected">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
</select>
This is a completely classic select
. I’ll skip over the page size change and the filter field, as they are nothing special either. HTMX already triggers a request when one of these fields is modified thanks to the value input from:[form=inventory-controller] delay:100ms
of the form's hx-trigger
attribute.
Once again, the use of purely HTML forms and fields allows the application to function correctly. The only thing to keep in mind is that the form will not auto-submit on change if HTMX is not present. Therefore, a submission button must be included that the user can click if JavaScript is not available for some reason.
<button
type="submit"
form="inventory-controller"
x-show="window.htmx == undefined"
>OK</button>
The button is not necessary if HTMX is loaded, so we use AlpineJS to hide it with x-show="window.htmx == undefined"
.
To see the result, you can disable JavaScript in your browser or block the htmx or alpineJS script. In any case, you will see two submission buttons appear next to the pagination controls and the search field.
Sorting
Sorting is slightly more subtle. The user activates sorting by clicking on the column header. The simplest for this kind of interaction is to use a link:
<a
href="?sort=-quantity&search=&page=2&pageSize=10"
hx-get="?sort=-name&search=&page=2&pageSize=10"
hx-target="#inventory"
hx-push-url="true">
Qty. ↓
</a>
The href
attribute allows the link to function without JavaScript, and the hx-*
attributes are there for the enhanced UX with HTMX. We include the sort
parameter for sorting, and all other parameters to not lose their state.
The problem with the link is that it contains no state. Consequently, if the user changes the page for example, the request sent by the form will not contain the sort parameter and the current sort will be lost.
To remedy this, we include a hidden field in the page that contains the current state of the sort:
<input type="hidden" name="sort" form="inventory-controller" value="quantity">
This way, every time the form that controls the table sends a request, the current sort will be sent as well. And it works without JavaScript.
Total Element Count / Event Synchronization
When an element is deleted or created, the total number of elements under the title is updated. However, this information is not part of the response body for creation and deletion requests.
For this kind of use case, HTMX allows an HTTP response to trigger an event that updates other parts of the page.
Here is the complete HTTP response to an element deletion request:
HTTP/1.1 200 OK
content-length: 0
hx-trigger: x-business:item-deleted
The hx-trigger
header informs htmx that an event must be raised by the element that made the request. In this case, an event of type x-business:item-deleted
. This is a classic HTML event that will propagate to the body of the document.
Elsewhere in the page, the total number of elements is implemented with this HTML code:
<hgroup
hx-trigger="x-business:item-created from:body, x-business:item-deleted from:body"
hx-get="/data-table/heading"
>
<h1>Inventory</h1>
<p>(42 items in stock)</p>
</hgroup>
The hx-trigger
attribute triggers a request when an x-business:item-deleted
or x-business:item-created
event is detected on the body, and retrieves the new total from the endpoint /data-table/heading
.
This is an example of how different parts of the same page can be synchronized without introducing coupling between them thanks to the event system.
Experience Feedback
In this article, I’ve presented some patterns and techniques used to develop applications with HTMX. The most important conclusion I can draw is that web fundamentals are fundamental.
Web Fundamentals
You need to rely heavily on links, forms, URLs, sessions, etc. Since the paradigm is new, a developer who has only known development with React or VueJS may have some difficulty adapting.
Nevertheless, this way of developing applications is infinitely simpler than today’s industry state of the art. There is less tooling, fewer abstraction layers, and the result is more resilient and simpler to evolve.
Disadvantages
There are concessions to make on features. For example, it seems difficult to me to develop a rich text editor solely with AlpineJS. However, these concessions seem minor compared to the gain in simplicity. Also remember that it is very easy to include a piece of React (for example) in the middle of a hypermedia application.
The main disadvantage in my opinion is the lack of an ecosystem around this approach. Although many people have understood the interest of HTMX, this approach remains marginal in today’s web. Consequently, you need to reinvent a bit more of your own tooling and technical foundation. We can hope that this limitation will disappear by itself with time.
-
That's also why all table cells contain a div, because it’s impossible to reduce the size of a cell below the size of its content. So we reduce the size of the div in the cell to 0... ↩
Top comments (0)