Empêcher la soumission multiple de formulaires côté client

Ce billet date de plusieurs années, ses informations peuvent être devenues obsolètes.

Au clic sur un bouton d'envoi de formulaire, on déclenche (généralement) une requête HTTP(S). À ce moment-là, on est jamais à l'abri d'un handshake TCP un peu laborieux à cause d'une congestion du réseau, d'un paquet perdu etc.

Si la latence dépasse un certain nombre de millisecondes, il peut arriver à l'utilisateur de perdre patience et de passer en mode mouse-click fever : je clique à fond sur le bouton jusqu'à ce que ça putain de marche. J'en connais qui font ça juste for the lulz. Résultat : autant de nouvelles requêtes HTTP(S) que de clics dans la file d'attente du navigateur.

C'est un problème classique du développement web, auquel on cherche des solutions depuis des années.

Idéalement il faut mettre une sécurité côté backend, mais les traitements sur les données reçues d'un formulaire sont tellement variés qu'il n'y a pas de solution miracle.

Des techniques côté frontend peuvent nous aider. Never trust the browser hein ! Mais ça peut quand même soulager le réseau et le backend par voie de conséquence.

L'idée est d'utiliser JavaScript pour autoriser une soumission unique par formulaire.

En poussant l'UX à son paroxysme on peut aussi imaginer remplacer le bouton de submit par un indicateur d'activité, l'exercice est laissé au lecteur.

Et maintenant sans transition, on va parler "jQuery" comme si on était en 2011 !

Avec jQuery

$(document).ready(function () {
  $("form").on("submit", function () {
    $(":submit", this).on("click", function () {
      return false
    })
  })
})

Ce code ajoute un écouteur d'événement submit aux éléments de type form déjà existants dans le document.

Quand la soumission d'un formulaire (via un clic de bouton) déclenche un événement submit, il est capté par cet écouteur qui déclenche son callback.

Dans le callback, on sait quel formulaire est manipulé car il est identifié par this :

When jQuery calls a handler, the this keyword is a reference to the element where the event is being delivered; for directly bound events this is the element where the event was attached and for delegated events this is an element matching selector.

Il nous sert dans la foulée de contexte de sélecteur pour trouver ses enfants de type button ou input ayant un attribut type="submit" afin de leur ajouter un écouteur d'événement click.

Le callback de cet écouteur neutralise les clics suivants en retournant false. Retourner false est une spécificité de jQuery :

Returning false from an event handler will automatically call event.stopPropagation() and event.preventDefault().

Pfiou, il y a quand même pas mal de trucs à dire pour 7 lignes de code, et je n'ai pas parlé de $(document).ready() !

J'ai insisté au début sur le fait qu'on ciblait les éléments déjà existants. C'est parce qu'avec .on() introduit en 2011 (ça y est, les connexions se font ?) on peut aussi faire de la délégation d'événements, c'est à dire attacher des écouteurs à des éléments qui existent déjà ou qui existeront dans le futur.

Pour les cools kids on peut faire dans le moderne avec des grosses flèches, au prix de la perte du binding de this :

$(document).ready(() => {
  $("form").on("submit", e => {
    $(":submit", e.currentTarget).on("click", e => false)
  })
})

On se console avec Event.currentTarget.

En JavaScript à la vanille

On bascule maintenant du côté des guerriers, celui des bonhommes qui n'ont pas besoin de jQuery.

Voilà une solution que j'ai trouvée pour obtenir le même code qu'en jQuery sans que ça devienne trop illisible :

document.addEventListener("DOMContentLoaded", e => {

  let cancelClick = e => {
    e.preventDefault()
    e.stopPropagation()
  }

  let preventMultipleSubmits = e => {
    let submits = e.currentTarget.querySelectorAll('[type="submit"]')
    submits.forEach(submit => {
      submit.addEventListener("click", cancelClick)
    })
    // Submit will happen 1 time only.
  }

  document.querySelectorAll("form").forEach(form => {
    form.addEventListener("submit", preventMultipleSubmits)
  })

})

DOMContentLoaded est maintenant bien supporté.

Pour faire un bulk addEventListener tu peux toujours te brosser, obligé de looper sur une collection d'éléments.

Et pour itérer sur une collection d'éléments, il faut déjà savoir sur quoi vous itérez ! Par exemple un getElementsByTagName va retourner une HTMLCollection alors que querySelectorAll va retourner une NodeList.

Et ces interfaces sont iterable depuis peu, ce qui oblige à faire des trucs bizarres pour les parcourir.

J'ai branché un forEach directement sur querySelectorAll dans mon exemple mais c'est très récent et NodeList.prototype.forEach() ne fonctionne pas dans IE, tout du moins dans certains IE.

Ça reste quand même bien verbeux, non ? Et je ne vous parle pas du cas dans lequel vous devez gérer la délégation.

Pour avoir moins de code, on peut changer de technique en utilisant un drapeau par exemple :

document.addEventListener("DOMContentLoaded", e => {

  let preventMultipleSubmits = e => {
    let form = e.currentTarget
    if (form.alreadySubmitted) {
      return e.preventDefault()
    }
    form.alreadySubmitted = true
    // Submit will happen 1 time only.
  }

  document.querySelectorAll("form").forEach(form => {
    form.addEventListener("submit", preventMultipleSubmits)
  })

})

Bref, tout ça pour vous dire que je ne rechigne jamais à sortir un petit jQuery quand l'occasion se présente. La volée de bashing qu'il a pris ces dernières années dénote une méconnaissance de l'histoire du DOM et un piètre niveau d'analyse de la complexité de l'interface native du DOM.

L'API de jQuery est ce que l'API du DOM aurait dû être.

Avant Resident Evil 2 (2019) Après Latence et bande passante

Tag Kemar Joint