HTMX : Recherche en temps réel

Sommaire

Comment j’ai implémenté une recherche instantanée sur 1000 contacts sans écrire de JavaScript.

Ce que je voulais

Une barre de recherche qui filtre ma liste de contacts pendant que je tape. Classique. Avec React, j’aurais fait un state pour la query, un useEffect pour debounce, un appel API, puis mise à jour du state des résultats.

Avec HTMX, c’est 3 attributs.

Le HTML

<input type="search"
       name="q"
       placeholder="Rechercher..."
       hx-get="/contacts"
       hx-target="#results"
       hx-trigger="keyup delay:500ms changed, change"
       hx-indicator="#spinner">

<span id="spinner" class="htmx-indicator">...</span>

<div id="results">
  <!-- les résultats arrivent ici -->
</div>

Décomposons :

  • hx-get="/contacts" : envoie une requête GET à /contacts avec la valeur du champ
  • hx-target="#results" : injecte la réponse dans ce div
  • hx-trigger="keyup delay:500ms changed" : déclenche 500ms après la dernière frappe
  • hx-indicator="#spinner" : affiche ce spinner pendant le chargement

Le delay:500ms est important. Sans ça, chaque frappe envoie une requête. Avec 1000 contacts et un utilisateur qui tape vite, ça peut vite saturer.

Le serveur (Go/Gin)

func getContacts(c *gin.Context) {
    query := c.Query("q")
    
    var contacts []Contact
    if query != "" {
        contacts = searchContacts(query) // filtre sur nom, email, tel
    } else {
        contacts = allContacts
    }
    
    // HTMX envoie ce header
    if c.Request.Header.Get("Hx-Request") == "true" {
        // Requête HTMX : on renvoie juste le fragment
        c.HTML(http.StatusOK, "contacts-list.html", gin.H{
            "Contacts": contacts,
            "Query":    query,
        })
        return
    }
    
    // Requête normale : page complète
    c.HTML(http.StatusOK, "index.html", gin.H{
        "Contacts": contacts,
        "Query":    query,
    })
}

Le header Hx-Request permet de distinguer une requête HTMX d’une requête classique. Si quelqu’un désactive JavaScript ou accède directement à l’URL, il a quand même la page complète.

Le template du fragment

<!-- contacts-list.html -->
<table>
  {{ range .Contacts }}
  <tr>
    <td>{{ .FirstName }} {{ .LastName }}</td>
    <td>{{ .Email }}</td>
    <td>{{ .Phone }}</td>
  </tr>
  {{ else }}
  <tr>
    <td colspan="3">Aucun résultat pour "{{ .Query }}"</td>
  </tr>
  {{ end }}
</table>

Rien de spécial. Du HTML classique avec du templating Go.

Le truc que j’ai découvert : Out-of-Band Swaps

Au début, ma recherche marchait mais le compteur “X contacts trouvés” ne se mettait pas à jour. Normal : il était en dehors du #results.

HTMX a une solution : les Out-of-Band Swaps. On peut mettre à jour plusieurs éléments avec une seule réponse.

<!-- Dans la réponse du serveur -->
<span id="count" hx-swap-oob="true">
  {{ len .Contacts }} contact(s)
</span>

<table>
  <!-- ... le tableau normal ... -->
</table>

L’attribut hx-swap-oob="true" dit à HTMX : “cet élément, tu le remplaces là où il y a le même ID dans la page, pas dans la target principale”.

J’utilise ça pour mettre à jour le compteur et les contrôles de pagination en même temps que les résultats.

Mise à jour de l’URL

Détail pratique : je voulais que l’URL reflète la recherche pour pouvoir partager un lien filtré.

<input ... hx-push-url="true">

Quand je tape “dupont”, l’URL devient /contacts?q=dupont. Si quelqu’un ouvre ce lien, il voit directement les résultats filtrés.

Performance

Avec 1000 contacts, la recherche est instantanée. Le serveur filtre en mémoire, génère le HTML, et l’envoie. Pas de parsing JSON côté client, pas de re-render React. Le navigateur reçoit du HTML prêt à afficher.

Sur des datasets plus gros, il faudrait paginer les résultats ou ajouter un vrai moteur de recherche. Mais pour une app interne avec quelques milliers d’entrées, ça suffit largement.

Le code complet

Tout est dans le repo : git.sr.ht/~gabrielivanes/htmx_todo

Articles liés :