DEV Community

Cover image for An advanced Data Table with HTMX
Benoit Averty for Zenika

Posted on • Originally published at benoitaverty.com

An advanced Data Table with HTMX

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

The three hx-* attributes are sufficient to code the deletion behavior.

  • hx-delete: when the form is submitted, a DELETE 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 first tr element in the ancestors of the form element will be replaced at the end of the request
  • hx-swap: outerHTML specifies that the entire tr element is replaced (by default, it would be only its content), and swap:300ms means that HTMX will apply an htmx-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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • hx-trigger allows more detailed specification of events that trigger a request. By default, forms trigger a request on the submit 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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&amp;search=&amp;page=2&amp;pageSize=10" 

        hx-get="?sort=-name&amp;search=&amp;page=2&amp;pageSize=10"
        hx-target="#inventory" 
        hx-push-url="true">
    Qty.&NonBreakingSpace;</a>
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.


  1. 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)