Un tableau de données avancé avec HTMX

28/02/2025 — 10 minutes de lecture (2175 mots)

Il y a quelques jours, j'ai terminé et publié une démonstration d'un composant `DataTable` réalisé avec HTMX et AlpineJS. Cet article décrit la démarche que j'ai mise en oeuvre et explique quelques patterns utilisés pour le développement de cette démo.

Contents

Le projet

L’objectif du projet est d’évaluer la pertinence de HTMX dans de nombreux cas d’usages, des sites orientés contenus à des applications de gestion complexes. Je cherche à me prouver (et aussi à prouver à d’autres) que HTMX et l’approche d’application hypermedia n’est pas limitée à des applications simples ou à des expériences utilisateur basiques.

Pour cela, j’ai initialisé un projet destiné à montrer divers patterns fréquemment rencontrés dans des applications modernes en les développant avec HTMX et l’écosystème que je désigne sous le nom d’«application hypermedia».

Vous pouvez interagir avec la démo à cette adresse : https://ktor-htmx-demo.ab0.fr

La stack technique

Ensemble, ces trois briques forment l’équivalent de la stack AHA (mais avec Ktor à la place de Astro), et le site explique très bien l’approche utilisée pour ce projet.

Il faut aussi noter que l’utilisation de AlpineJS reste modérée : l’interactivité apportée par Alpine est, selon moi, moins maintenable qu’avec un framework frontend du type VueJS ou React. Alpine est intéressant parce qu’il est extrêmement complémentaire d’HTMX, mais je ne l’utilise qu’avec parcimonie, et uniquement pour améliorer un défaut d’UX qui serait trop important avec du HTMX pur.

Ce que cet article n’est pas

Cet article n’est pas un argumentaire pour adopter ou non HTMX et l’approche hypermedia. J’ai plutôt l’ambition de fournir de la matière pour aider à choisir en montrant concrètement ce qu’il est possible, et en démontant certaines idées reçues.

Cet article ne montre pas le code utilisé côté serveur pour générer le HTML “enrichi” par HTMX. Je montrerai le code HTML/HTMX final, et vous pourriez le générer avec n’importe quelle technologie serveur pour obtenir la même expérience utilisateur.

La démo

Cette démo montre une DataGrid telle qu’on peut en trouver dans la plupart des applications de gestion. Elle permet d’interagir avec une collection d’objets stockés en base de donnée ou autre source. En l’occurrence, il s’agit d’une liste d’objets qu’on pourrait trouver dans un inventaire, avec quantité en stock, fournisseur, catégorie, etc.

La liste permet de

Le résultat obtenu en manipulant ces différents critères possède une URL qu’on peut partager pour retrouver la liste dans le même état.

L’ensemble des fonctionnalités fonctionne si on désactive javascript ou si l’un des scripts (htmx ou alpine) échoue lors du téléchargement ou de l’exécution.

Toutes les fonctionnalités sont accessibles au clavier.

Structure générale

Les ressources externes se limitent à HTMX (et un plugin), AlpineJS (et deux plugins), le CSS. Au total, moins de 50ko (minifié / compressé).

<html>
  <head>
    <link href="/app.css" rel="stylesheet" type="text/css">
    <link href="/pico.min.css" rel="stylesheet" type="text/css">
  </head>
  <body>
    <!-- Le 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>

L’élément principal de la page est bien sur un tableau HTML avec ses entêtes


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

Ce tableau affiche la donnée de manière tout à fait classique, mais contient déjà un certain nombre d’attributs HTMX et d’éléments HTML qui méritent des explications.

Suppression d’un élément

La dernière colonne du tableau contient le formulaire bouton suivant :

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

Les trois attributs hx-* suffisent à coder le comportement de suppression.

Comme la réponse du serveur est vide, la ligne de tableau est simplement supprimée, ce qui suffit pour notre feature de suppression.

Pourquoi swap:300ms ?

Les UX modernes se reposent énormément sur les animations. En plus d’offrir un look & feel plus agréable, elles fournissent une forme de feedback et permettent à l’utilisateur de mieux constater le résultat de son action.

Le mécanisme de HTMX activé par swap:300ms permet d’ajouter une animation sur notre suppression à l’aide des transitions CSS1.

.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;
}

Pourquoi utiliser un formulaire et un bouton de type submit ?

HTMX permet justement d’effectuer des requêtes à partir de n’importe quel élément. On aurait donc pu se passer du formulaire et utiliser un simple button avec les trois mêmes attributs hx-*.

L’utilisation du formulaire permet à l’application de fonctionner même si javascript est désactivé ou si le script htmx.js échoue à s’exécuter pour une raison ou une autre. Si HTMX n’est pas chargé, le navigateur va se charger lui-même de faire une requête http grâce aux attributs method et action. Dans ce cas, le serveur va aussi effectuer la suppression et rediriger l’utilisateur sur l’url ou il se trouvait. Il n’y aura pas d’animation et la page entière sera rechargée, mais la suppression fonctionne parfaitement. C’est un exemple de progressive enhancement rendu relativement simple à coder grâce au paradigme de l’application hypermedia.

Pagination / Tri / Filtrage

Ces trois fonctionnalités sont similaires, car elles permettent de contrôler les éléments qu’on affiche dans le tableau. De plus, elles doivent fonctionner ensemble (changer de page ne doit pas réinitialiser le filtre par exemple). Enfin, ce sont ces fonctionnalités qui vont changer l’URL de la page courante pour permettre à l’utilisateur de mettre en favoris ou de partager n’importe quel état du tableau.

Côté serveur, c’est relativement simple : une requête sur l’URL /data-table accepte des query params qui contrôlent les éléments affichés (sort, page, pageSize, search). De plus, le serveur détecte si une requête a été faite par HTMX grâce au header HX-Request et ne retourne que le tableau (et pas le document entier) si une requête est faite par HTMX.

Côté client, il faut donc utiliser les attributs qui déclencheront une requête GET avec ces paramètres. Il existe un élément natif à HTML qui permet de stocker un état et de déclencher des requêtes à partir de cet état, c’est le formulaire. Nous allons donc déclencher nos requêtes à partir d’un formulaire.

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

À ce stade, je n’ai pas encore mentionné les champs de formulaire. Il faut savoir que les champs de formulaire (éléments input, button, select, etc.) n’ont pas nécessairement besoin d’être des descendants de l’élément form. Les champs sont donc à l’endroit approprié dans la suite du document, reliés au formulaire grâce à leur attribut form

<!-- Changement de page -->
<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>

Il s’agit d’un select tout à fait classique. Je vous passe le changement de taille de page, et le champ de filtrage, car ils n’ont rien de spécial non plus. HTMX déclenche déjà une requête quand on modifie l’un de ces champs grâce à la valeur input from:[form=inventory-controller] delay:100ms de l’attribut hx-trigger du formulaire

Encore une fois, l’utilisation de formulaire et de champs purement HTML permet à l’application de fonctionner correctement. La seule chose à laquelle il faut penser c’est que le formulaire ne sera pas auto-soumis au changement si HTMX n’est pas présent. Il faut donc inclure un bouton de soumission sur lequel l’utilisateur peut cliquer si JavaScript n’est pas présent pour une raison ou une autre.

<button
        type="submit"
        form="inventory-controller" 
        x-show="window.htmx == undefined"
>OK</button>

Le bouton n’est pas nécéssaire si HTMX est chargé, donc on utilise alpineJS pour le cacher avec x-show="window.htmx == undefined". Pour voir le résultat, vous pouvez désactiver JavaScript dans votre navigateur ou bloquer le script htmx ou alpineJS. Dans tous les cas, vous verrez deux boutons de soumission apparaitre à côté des contrôles de pagination et du champ de recherche.

Tri

Le tri est légèrement plus subtil. L’utilisateur active le tri en cliquant dans l’entête de colonne. Le plus simple pour ce genre d’interaction, c’est d’utiliser un lien :

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

L’attribut href permet au lien de fonctionner sans JavaScript, et les attributs hx-* sont là pour l’UX améliorée avec HTMX. On inclut le paramètre sort pour le tri, et tous les autres paramètres pour ne pas perdre leur état.

Le problème du lien, c’est qu’il ne contient pas d’état. Par conséquent, si l’utilisateur change la page par exemple, la requête envoyée par le formulaire ne contiendra pas le paramètre de tri et le tri actuel sera perdu.

Pour remédier à ça, on inclut dans la page un champ caché qui contient l’état actuel du tri

<input type="hidden" name="sort" form="inventory-controller" value="quantity">

De cette façon, à chaque fois que le formulaire qui contrôle le tableau envoie une requête, le tri actuel sera envoyé aussi. Et ça fonctionne sans javascript.

Le décompte d’éléments total / synchronisation par événements

Lorsqu’on supprime ou qu’on crée un élément, le nombre total d’éléments sous le titre se met à jour. Pourtant, cette information ne fait pas partie du corps de la réponse des requêtes de création et de suppression.

Pour ce genre de cas d’usage, HTMX permet à une réponse HTTP de déclencher un évènement qui permet de mettre à jour d’autres parties de la page.

Voivi la réponse HTTP complète à une requête de suppression d’un élément :

HTTP/1.1 200 OK
content-length: 0
hx-trigger: x-business:item-deleted

Le header hx-trigger informe htmx qu’un évènement doit être levé par l’élément qui a fait la requête. En l’occurrence un évènement de type x-business:item-deleted. Cet évènement est un évènement HTML classique qui va se propager jusqu’au body du document.

Ailleurs dans la page, le nombre total d’éléments est implémenté avec ce code HTML :

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

L’attribut hx-trigger déclenche une requête quand un évènement x-business:item-deleted ou x-business:item-created est détecté sur le body, et récupère le nouveau total sur l’endpoint /data-table/heading.

C’est un exemple de la façon dont on peut synchroniser plusieurs parties différentes d’une même page, sans introduire de couplage entre elles grâce au système d’évènements.

Retour d’expérience

Dans cet article, j’ai présenté quelques patterns et techniques utilisées pour développer des applications avec HTMX. La conclusion la plus importante que je peux en tirer, c’est que les bases du web sont fondamentales.

Fondamentaux du web

Il faut énormément se reposer sur les liens, les formulaires, l’URL, les sessions etc. Le paradigme étant nouveau, un développeur qui n’a connu que le développement avec React ou VueJS pourra avoir quelques difficultés à s’adapter.

Néanmoins, cette façon de développer des applications est infiniment plus simple que l’état de l’art de l’industrie d’aujourd’hui. Il y a moins de tooling, moins de couches d’abstraction et le résultat est plus résilient et plus simple à faire évoluer.

Inconvénients

Il y a des concessions à faire sur les fonctionnalités. Par exemple, il me semble difficile de développer un éditeur de texte riche uniquement avec AlpineJS. Cependant, ces concessions me paraissent faibles par rapport au gain en simplicité. N’oubliez pas également qu’il est très facile d’inclure un morceau de React (par exemple) au milieu d’une application hypermedia.

Le principal inconvénient selon moi est le manque d’écosystème autour de cette approche. Bien que de nombreuses personnes aient compris l’intérêt d’HTMX, cette approche reste marginale dans le web aujourd’hui. En conséquence, il faut un peu plus réinventer son propre tooling et son socle technique. On peut espérer que cette limitation disparaitra d’elle-même avec le temps.


  1. C’est également pour ça que toutes les cellules de tableau contiennent une div, car il est impossible de réduire la taille d’une cellule en dessous de la taille de son contenu. On réduit donc la taille de la div dans la cellule à 0…