HTMX : Pagination et tri sans state client
Comment gérer pagination et tri côté serveur en gardant une navigation fluide.
Le piège du state côté client
Avec React, j’aurais eu un state pour la page courante, un pour le tri, un pour l’ordre. À chaque changement, re-fetch des données, mise à jour du state, re-render. Et il faut synchroniser tout ça avec l’URL si on veut des liens partageables.
Avec HTMX, le serveur gère tout. Le client ne garde rien en mémoire.
Pagination simple
<div id="contacts-list">
<!-- ... le tableau ... -->
<div class="pagination">
{{ if .HasPrev }}
<a hx-get="/contacts?page={{ sub .Page 1 }}"
hx-target="#contacts-list">
← Précédent
</a>
{{ end }}
<span>Page {{ .Page }} / {{ .TotalPages }}</span>
{{ if .HasNext }}
<a hx-get="/contacts?page={{ add .Page 1 }}"
hx-target="#contacts-list">
Suivant →
</a>
{{ end }}
</div>
</div>
Chaque lien de pagination fait une requête avec le numéro de page. Le serveur renvoie le tableau avec la pagination mise à jour. Pas de state à gérer côté client.
Le serveur
func getContacts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize := 10
contacts := allContacts
total := len(contacts)
totalPages := (total + pageSize - 1) / pageSize
// Extraction de la page
start := (page - 1) * pageSize
end := start + pageSize
if end > total {
end = total
}
contacts = contacts[start:end]
data := gin.H{
"Contacts": contacts,
"Page": page,
"TotalPages": totalPages,
"HasPrev": page > 1,
"HasNext": page < totalPages,
}
if c.Request.Header.Get("Hx-Request") == "true" {
c.HTML(http.StatusOK, "contacts-list.html", data)
} else {
c.HTML(http.StatusOK, "index.html", data)
}
}
Ajouter le tri
J’ai des colonnes cliquables pour trier. Le premier clic trie en ascendant, le deuxième en descendant.
<thead>
<tr>
<th>
<a hx-get="/contacts?sort=firstname&order={{ if and (eq .Sort "firstname") (eq .Order "asc") }}desc{{ else }}asc{{ end }}"
hx-target="#contacts-list">
Prénom
{{ if eq .Sort "firstname" }}
{{ if eq .Order "asc" }}↑{{ else }}↓{{ end }}
{{ end }}
</a>
</th>
<!-- ... autres colonnes ... -->
</tr>
</thead>
C’est verbeux dans le template, mais ça marche. Le serveur reçoit sort et order, trie, et renvoie.
Le problème : combiner les paramètres
Quand je trie, je perds la page. Quand je pagine, je perds le tri. Il faut propager tous les paramètres.
Ma première approche, moche mais fonctionnelle :
<a hx-get="/contacts?page={{ add .Page 1 }}&sort={{ .Sort }}&order={{ .Order }}&q={{ .Query }}"
hx-target="#contacts-list">
Suivant →
</a>
Ça marche, mais c’est pénible à maintenir. Chaque fois que j’ajoute un paramètre, je dois le propager partout.
Solution : helper dans le template
J’ai créé une fonction Go pour construire les query params :
// Dans main.go
router.SetFuncMap(template.FuncMap{
"buildQuery": func(params ...string) string {
// params = ["page", "2", "sort", "name", ...]
values := url.Values{}
for i := 0; i < len(params); i += 2 {
if params[i+1] != "" {
values.Set(params[i], params[i+1])
}
}
return values.Encode()
},
})
Dans le template :
<a hx-get="/contacts?{{ buildQuery "page" (add .Page 1 | toString) "sort" .Sort "order" .Order "q" .Query }}"
hx-target="#contacts-list">
Suivant →
</a>
Plus propre, et je peux ajouter des paramètres sans tout casser.
Mise à jour de l’URL
Pour que l’URL reflète l’état (page, tri, recherche), j’ajoute hx-push-url="true" sur le conteneur :
<div id="contacts-list" hx-push-url="true">
Quand je clique sur “Page 2”, l’URL devient /contacts?page=2. Je peux partager ce lien, et la personne arrive directement sur la page 2.
Performance avec 1000 entrées
Trier 1000 contacts en mémoire, c’est instantané. Le goulot d’étranglement, c’est le rendu HTML côté serveur. Avec Go et des templates simples, je suis sous les 10ms.
Si j’avais 100 000 contacts, il faudrait :
- Paginer côté base de données (LIMIT/OFFSET ou curseurs)
- Indexer les colonnes de tri
- Peut-être mettre du cache
Mais pour une app de gestion interne avec quelques milliers d’entrées, pas besoin d’optimiser.
Ce que j’ai appris
Le plus gros changement mental : accepter que le serveur soit la source de vérité. Pas de state client à synchroniser, pas de bugs où l’affichage ne correspond pas aux données.
Chaque clic fait un aller-retour serveur. Sur une app moderne avec React, ça semblerait aberrant. En pratique, avec un serveur proche et des réponses légères (juste du HTML), c’est imperceptible.
Le code
Repo : git.sr.ht/~gabrielivanes/htmx_todo
Articles liés :