Advanced Data Table with HTMX

2/28/2025 — 10 minutes read (2049 words)

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.

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://ktor-htmx-demo.ab0.fr

Technical Stack

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:

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.

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>

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&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>

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.


  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…