Un tableau de données avancé avec HTMX

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
- HTMX: La bibliothèque qui permet d’envisager cette approche. HTMX permet le développement d’applications avec HTML et HTTP, mais en levant tout un ensemble de limitations, en particulier en permettant d’interagir avec le serveur sans nécessairement recharger toute la page.
- AlpineJS: Une expérience utilisateur poussée nécessite quand même un certain degré d’interactivité côté client. AlpineJS fournit cette interactivité en restant dans le paradigme d’application hypermedia. L’interactivité reste codée directement dans le markup HTML, sans écrire de JS (ou presque). Là où HTMX se concentre sur les interactions client/serveur, AlpineJS permet d’ajouter un peu de comportement côté client.
- Kotlin / Ktor : Ktor est un framework kotlin qui permet de développer des APIs HTTP. C’est un peu l’équivalent kotlin d’Express ou Fastify. L’un des avantages de l’approche Hypermedia, c’est qu’elle permet de développer dans un autre langage que Javascript, et j’ai saisi cette opportunité pour ces démos.
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
- Trier, filter, et accéder aux pages de données
- Supprimer des éléments
- Créer un nouvel élément
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.
hx-delete
: à la soumission du formulaire, une requêteDELETE
est envoyée au serveur à l’URL spécifiée. Le serveur se charge de supprimer l’item en base, et renvoie une réponse vide avec le status code 200.hx-target="closest tr"
: le premier élémenttr
dans les ancêtres de l’élémentform
sera remplacé à l’issue de la requêtehx-swap
:outerHTML
spécifie qu’on remplace l’élémenttr
entier (par défaut, ça serait son contenu uniquement), etswap:300ms
signifie que HTMX va appliquer une classehtmx-swapping
à l’élément pendant 300 millisecondes avant le remplacement.
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>
hx-trigger
permet de spécifier plus finement les évènements qui déclenchent une requête. Par défaut, les formulaires déclenchent une requête sur l’évènementsubmit
. Ici, on déclenche aussi la requête si un élément input appartenant à ce formulaire change. C’est ce qui permet de réagir automatiquement quand on tape une requête texte ou quand on change de page.delay:100ms
permet d’éviter d’envoyer trop de requêtes quand un utilisateur tape rapidement un texte dans le champ de recherche.hx-get="/data-table"
spécifie le verbe et l’URL de la requête effectuée par HTMXhx-target
: Lorsque le serveur reçoit une requête à cette url, il renvoie le tableau HTML correspondant aux critères. HTMX va l’insérer dans la page à la place de l’élément correspondant au sélecteur#inventory
hx-push-url
permet de spécifier que l’URL de la page doit être mise à jour par l’URL de la requête. Ici, ça permet d’ajouter les query params correspondant aux champs du formulaire à l’url de la page. De cette manière, l’utilisateur peut rafraichir sa page ou la transmettre sans perdre l’état du tableau.
À 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&search=&page=2&pageSize=10"
hx-get="?sort=-name&search=&page=2&pageSize=10"
hx-target="#inventory"
hx-push-url="true">
Qty. ↓
</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.
-
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… ↩