Envoi de formulaire HTML

Envoi de formulaire HTML7 min de lecture

Envoyer un formulaire HTML sans recharger la page

Envoyez un formulaire avec fetch et FormData pour que le visiteur reste sur la page, avec erreurs de champ, état de chargement, nouvelles tentatives et solution de repli quand les scripts échouent.

Preet SutharFondateur, Ikiform

Écoutez l'événement submit du formulaire, appelez preventDefault() et envoyez new FormData(form) avec fetch et un en-tête Accept: application/json. Ikiform répond alors en JSON au lieu d'une redirection : 201 en cas de succès, ou des erreurs de champ que vous affichez à côté de chaque input. Gardez une vraie action et une vraie method sur le formulaire pour qu'il s'envoie encore de façon classique si votre script ne s'exécute jamais.

L'exemple ici est un encadré Cette page vous a-t-elle été utile ? en bas d'une page de documentation. C'est le cas typique où l'on reste sur la page : le lecteur est en pleine tâche, et l'envoyer sur une page de remerciement pour enregistrer un simple clic serait absurde. Le même pattern fonctionne pour n'importe quel formulaire.

Ce qui change quand vous prenez la main sur l'envoi

Un envoi de formulaire classique vous offre beaucoup sans effort : le navigateur affiche un indicateur de chargement, rend les doubles clics peu gênants, gère le bouton retour et affiche la page qui revient. Dès que vous appelez preventDefault(), tout cela devient votre travail. L'appel fetch lui-même tient en trois lignes ; le reste de cette page, c'est le travail que faisait le navigateur.

Ce que fait un script quand il envoie un formulaire avec fetch au lieu de laisser le navigateur naviguerévénementsubmitaprès lesvérificationsdu navigateurpreventDefault()pas denavigationfetch avecFormDataAccept :application/json201afficher uneligne deremerciementmessage surle chample focus s'ydéplace, leboutonrefonctionne422 ou erreur réseauCe que fait un script quand il envoie un formulaire avec fetch au lieu de laisser le navigateur naviguerévénement submitaprès lesvérifications dunavigateurpreventDefault()pas de navigationfetch avecFormDataAccept :application/json201afficher une ligne deremerciementmessagesur lechample focus s'ydéplace, leboutonrefonctionne422 ou erreur réseau
  1. Le navigateur exécute d'abord sa vérification des contraintes. L'événement submit ne se déclenche que si le formulaire est valide.
  2. Votre écouteur appelle preventDefault, donc l'onglet ne navigue pas.
  3. Il envoie les données du formulaire avec fetch et demande du JSON.
  4. Sur un 201, il remplace le formulaire par un message de statut et y déplace le focus.
  5. Sur un 422 ou une panne réseau, il affiche le message à côté du champ, y place le focus et réactive l'envoi.
La validation du navigateur s'exécute toujours avant votre code. Tout ce qui suit preventDefault est à votre charge.
Ce que faisait le navigateurCe que doit faire votre script
Affichait un état de chargement dans l'ongletEn afficher un, et l'annoncer aux lecteurs d'écran
Rendait les doubles clics presque sans effetIgnorer un second envoi tant qu'un premier est en cours
Affichait la page d'erreurAssocier error.fields à chaque input
Affichait une page d'erreur réseauIntercepter les requêtes échouées et les délais dépassés
Déplaçait le focus vers la nouvelle pageDéplacer le focus vers le résultat ou le premier champ invalide

Le formulaire

Trois champs : un oui ou non obligatoire, un commentaire facultatif et l'adresse de la page remplie par le script. Essayez la définition ci-dessous ; c'est celle qu'Ikiform impose sur le serveur, donc un commentaire de plus de 2 000 caractères reçoit aussi un 422 ici.

Aperçu

Exécute le validateur d'Ikiform dans votre navigateur. Rien n'est envoyé.

Cette page vous a-t-elle été utile ?
Collez ceci dans l'onglet Fields du formulaire. Avec des champs définis, Ikiform refuse aussi tout champ qu'il ne connaît pas.
feedback.html
<form id="page-feedback" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <fieldset>
    <legend>Was this page helpful?</legend>
    <label><input type="radio" name="helpful" value="yes" required /> Yes</label>
    <label><input type="radio" name="helpful" value="no" /> No</label>
  </fieldset>

  <label for="comment">What was missing?</label>
  <textarea id="comment" name="comment" rows="3" maxlength="2000" aria-describedby="comment-error"></textarea>
  <p id="comment-error" hidden></p>

  <input type="hidden" name="page" />
  <input type="hidden" name="_redirect" value="https://docs.example.com/feedback-sent" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
  <p id="feedback-status" role="status" tabindex="-1"></p>
</form>

<script type="module" src="/page-feedback.js"></script>

Le formulaire est complet sans le script. Avec JavaScript désactivé ou bloqué, il s'envoie de façon classique, Ikiform répond 303 et le lecteur arrive sur la page _redirect. Le script ne fait qu'améliorer cela.

Le script

page-feedback.js
const form = document.querySelector("#page-feedback");
const status = form.querySelector("#feedback-status");
const button = form.querySelector("button");
const pageInput = form.elements.namedItem("page");
const idempotencyKey = crypto.randomUUID();
let sending = false;

pageInput.value = location.href;

function showFieldErrors(fields) {
  for (const input of form.querySelectorAll("[aria-invalid]")) input.removeAttribute("aria-invalid");
  for (const hint of form.querySelectorAll("[id$='-error']")) hint.hidden = true;
  let first = null;
  for (const [name, messages] of Object.entries(fields)) {
    const control = form.elements.namedItem(name);
    const hint = form.querySelector(`#${name}-error`);
    if (control instanceof HTMLElement) control.setAttribute("aria-invalid", "true");
    if (hint) {
      hint.textContent = messages[0];
      hint.hidden = false;
    }
    first ??= control instanceof RadioNodeList ? control[0] : control;
  }
  first?.focus();
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  if (sending) return;
  const body = new FormData(form, event.submitter);
  body.set("_idempotency", idempotencyKey);
  sending = true;
  button.setAttribute("aria-disabled", "true");
  status.textContent = "Sending...";

  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body,
      signal: AbortSignal.timeout(15000),
    });
    const result = await response.json();

    if (result.ok) {
      form.replaceChildren(status);
      status.textContent = "Thanks, that helps.";
      status.focus();
      return;
    }
    if (result.error.code === "challenge_required") {
      form.submit();
      return;
    }
    if (result.error.fields) {
      status.textContent = "";
      showFieldErrors(result.error.fields);
    } else if (response.status === 429 && response.headers.has("Retry-After")) {
      const wait = response.headers.get("Retry-After");
      status.textContent = `Too many tries. Wait ${wait} seconds and send again.`;
    } else {
      status.textContent = result.error.message;
    }
  } catch {
    status.textContent = "Couldn't reach the server. Check your connection and send again.";
  } finally {
    sending = false;
    button.removeAttribute("aria-disabled");
  }
});
  • new FormData(form, event.submitter) lit chaque contrôle nommé, plus le name et la value du bouton cliqué s'il en a. Construisez-le avant de modifier quoi que ce soit dans le formulaire.
  • Le bouton reçoit aria-disabled, pas disabled. C'est l'indicateur sending qui bloque réellement, et le bouton reste focalisable pour que les utilisateurs de lecteurs d'écran ne perdent pas leur position.
  • Pas d'en-tête Content-Type. Avec un corps FormData, le navigateur définit multipart/form-data avec la bonne délimitation.
  • Accept: application/json fait répondre Ikiform en JSON. Un appel fetch envoie Sec-Fetch-Mode: cors, qui choisirait JSON de toute façon, mais l'en-tête rend l'intention explicite.
  • Une clé d'idempotence par chargement de page, envoyée dans le champ _idempotency. Si le lecteur envoie, que le réseau perd la réponse et qu'il renvoie, Ikiform rejoue la première réponse au lieu de stocker une seconde soumission. Un envoi refusé ne consomme jamais la clé, donc un renvoi corrigé passe. L'en-tête Idempotency-Key fait la même chose, mais un en-tête personnalisé oblige le navigateur à envoyer d'abord une requête de preflight. JavaScript et fetch donne les règles exactes.
  • AbortSignal.timeout(15000) transforme une requête bloquée en passage dans le catch au lieu d'un Sending... sans fin.
  • La ligne de statut a role="status", donc son texte est lu sans déplacer le focus. En cas de succès, le focus s'y déplace car les champs autour ont disparu.

Quand le formulaire a une vérification humaine

Si le Spam check du formulaire est réglé sur Ikiform, un envoi fetch sans jeton Turnstile reçoit 403 challenge_required. Le script ci-dessus le gère en appelant form.submit(). Cette méthode ignore l'écouteur submit et envoie de façon classique, et pour un envoi classique Ikiform affiche sa propre courte page de vérification, puis continue vers votre page _redirect. Le lecteur ne quitte la page que lorsque la vérification est active.

Pour rester sur la page même dans ce cas, obtenez d'abord un jeton avec challenge(formId) du SDK et passez-le à submit(). La documentation sur le spam montre le déroulement.

L'alternative sans JavaScript

Avant fetch, l'astuce consistait à cibler une iframe masquée, pour que la réponse s'y charge et que la page ne bouge pas :

Ancienne astuce
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
  ...
</form>

Cela fonctionne encore, mais l'iframe contient une page sur ikiform.com, et le navigateur ne laisse pas votre script lire un cadre d'une autre origine. Vous ne pouvez pas distinguer un succès d'une erreur de validation, donc le lecteur n'a aucun retour dans un cas comme dans l'autre. Utilisez-la seulement là où un script ne peut vraiment pas s'exécuter.

Un framework serveur peut vous éviter le rechargement sans code fetch côté client : les server actions Next.js, le use:enhance de SvelteKit et le <Form> de React Router envoient de façon classique avant l'hydratation de la page et sans rechargement ensuite. La vue d'ensemble les montre tous.

Vérifiez que tout fonctionne

  1. Dans Allowed domains du formulaire, ajoutez le domaine de votre site, plus localhost pendant le développement. Laissez vide pour accepter les envois de partout.
  2. Envoyez Oui sans commentaire. Le formulaire devient Merci, cela nous aide. et le panneau Réseau affiche 201.
  3. Collez plus de 2 000 caractères dans le commentaire, retirez maxlength dans les outils de développement et envoyez. Le message apparaît sous le commentaire et le focus s'y déplace.
  4. Double-cliquez sur Envoyer. Le tableau de bord affiche une seule soumission, pas deux.
  5. Désactivez JavaScript et envoyez. Vous arrivez sur la page _redirect.
  6. Réglez la limitation réseau sur Offline et envoyez. La ligne de statut indique qu'elle n'a pas pu joindre le serveur, et Envoyer fonctionne à nouveau.

Pièges

ProblèmeCause et solution
Un champ manque dans l'envoiIl était disabled quand FormData a été construit. Les contrôles désactivés ne sont jamais envoyés. Construisez d'abord les données, ou verrouillez les inputs avec readonly
400 invalid_body ou une soumission videLe code a défini Content-Type: multipart/form-data à la main, sans délimitation. Supprimez l'en-tête
La page se recharge quand mêmeL'écouteur a levé une exception avant preventDefault(), ou le clic est arrivé avant le chargement du script. Appelez preventDefault() en premier ; un clic précoce envoie de façon classique, ce qui fonctionne quand même
response.json() lève une exceptionLa requête est partie vers un endroit qui a répondu en HTML, généralement une action relative. Lisez form.action dans la console ; ce doit être votre URL Ikiform
403 challenge_requiredLa vérification humaine du formulaire est active. Rabattez-vous sur form.submit() comme ci-dessus, ou obtenez un jeton avec challenge()
403 origin_not_allowed, ou tous les envois finissent en spamLe domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, plus localhost pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.