HTMX : Validation de formulaires côté serveur
Pourquoi j’ai choisi de valider mes formulaires côté serveur et comment HTMX rend ça fluide.
Le problème de la double validation
Sur la plupart des projets, on valide deux fois : côté client (pour l’UX) et côté serveur (pour la sécurité). Ça veut dire maintenir les mêmes règles à deux endroits. Quand on ajoute une contrainte, il faut penser à la mettre des deux côtés.
Sur mon app de contacts, j’ai décidé de ne valider que côté serveur. HTMX permet de le faire sans que l’utilisateur attende la soumission du formulaire pour voir les erreurs.
L’idée
Chaque champ envoie sa valeur au serveur quand il perd le focus. Le serveur valide et renvoie soit rien (c’est bon), soit un message d’erreur. L’utilisateur voit l’erreur immédiatement, comme avec une validation JavaScript.
Le HTML
<form hx-post="/contacts/new" hx-target="body">
<div>
<label>Email</label>
<input type="email"
name="email"
hx-get="/contacts/validate-email"
hx-target="#email-error"
hx-trigger="blur, keyup delay:500ms changed"
hx-include="[name='first_name'], [name='last_name']">
<span id="email-error" class="error"></span>
</div>
<div>
<label>Téléphone</label>
<input type="tel"
name="phone"
hx-get="/contacts/validate-phone"
hx-target="#phone-error"
hx-trigger="blur">
<span id="phone-error" class="error"></span>
</div>
<button type="submit">Créer</button>
</form>
Le hx-trigger="blur" déclenche la validation quand on quitte le champ. J’ai ajouté keyup delay:500ms sur l’email parce que les utilisateurs veulent souvent savoir rapidement si leur email est déjà pris.
Validation d’unicité
C’est là que la validation serveur prend tout son sens. Vérifier qu’un email n’existe pas déjà, c’est impossible côté client sans appeler le serveur de toute façon.
func validateEmail(c *gin.Context) {
email := c.Query("email")
contactId := c.Query("contact_id") // pour l'édition
// Format
if email != "" && !isValidEmail(email) {
c.String(http.StatusOK, "Format d'email invalide")
return
}
// Unicité
if email != "" && emailExistsForOther(email, contactId) {
c.String(http.StatusOK, "Cet email est déjà utilisé")
return
}
// OK : on renvoie une chaîne vide
c.String(http.StatusOK, "")
}
Le serveur renvoie juste du texte. Si c’est vide, pas d’erreur. Sinon, HTMX injecte le message dans le span.
Le cas de l’édition
Quand on édite un contact existant, il faut exclure son propre email de la vérification d’unicité. D’où le hx-include :
<input type="hidden" name="contact_id" value="{{ .Contact.Id }}">
<input type="email"
name="email"
hx-get="/contacts/validate-email"
hx-target="#email-error"
hx-include="[name='contact_id']">
Le hx-include ajoute d’autres champs à la requête. Le serveur reçoit l’ID et peut ignorer ce contact dans la vérification.
Validation croisée
Pour vérifier que prénom + nom sont uniques ensemble :
<input type="text"
name="first_name"
hx-get="/contacts/validate-name"
hx-target="#name-error"
hx-include="[name='last_name'], [name='contact_id']">
Le serveur reçoit les deux valeurs et peut vérifier la combinaison.
Validation à la soumission
La validation inline ne suffit pas. Un utilisateur peut soumettre sans avoir quitté les champs. Je valide donc aussi à la soumission :
func createContact(c *gin.Context) {
contact := Contact{
FirstName: c.PostForm("first_name"),
LastName: c.PostForm("last_name"),
Email: c.PostForm("email"),
Phone: c.PostForm("phone"),
}
errors := validateContact(contact)
if len(errors) > 0 {
c.HTML(http.StatusOK, "contact-form.html", gin.H{
"Contact": contact,
"Errors": errors,
})
return
}
saveContact(contact)
c.Redirect(http.StatusSeeOther, "/contacts")
}
Si la validation échoue, je renvoie le formulaire avec les erreurs pré-remplies. L’utilisateur ne perd pas sa saisie.
Ce que j’y gagne
- Une seule source de vérité : les règles sont côté serveur, point.
- Validation d’unicité gratuite : le serveur a accès à la base.
- Pas de désync : impossible d’avoir des règles différentes client/serveur.
- Moins de code : pas de validation JavaScript à maintenir.
Le seul inconvénient : chaque validation fait un aller-retour réseau. Sur une connexion lente, ça peut se sentir. En pratique, sur une app interne en réseau local, c’est imperceptible.
Le code
Repo complet : git.sr.ht/~gabrielivanes/htmx_todo
Articles liés :