# 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.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/html-form-submission/without-page-reload

É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.

Schéma : Ce que fait un script quand il envoie un formulaire avec fetch au lieu de laisser le navigateur naviguer

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 navigateur | Ce que doit faire votre script |
| --- | --- |
| Affichait un état de chargement dans l'onglet | En afficher un, et l'annoncer aux lecteurs d'écran |
| Rendait les doubles clics presque sans effet | Ignorer un second envoi tant qu'un premier est en cours |
| Affichait la page d'erreur | Associer `error.fields` à chaque input |
| Affichait une page d'erreur réseau | Intercepter les requêtes échouées et les délais dépassés |
| Déplaçait le focus vers la nouvelle page | Dé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.

Formulaire : Was this page helpful

- **Cette page vous a-t-elle été utile ?** (`helpful`, radio, obligatoire)
- **Que manquait-il ?** (`comment`, textarea)
- **Page** (`page`, url)

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`:

```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`:

```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](https://ikiform.com/fr/docs/frameworks.md#idempotency) 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](https://ikiform.com/fr/docs/sdk.md) et passez-le à `submit()`. La [documentation sur le spam](https://ikiform.com/fr/docs/spam.md#human-check) 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`:

```html
<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](https://ikiform.com/fr/guides/html-form-submission.md#implementation) 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ème | Cause et solution |
| --- | --- |
| Un champ manque dans l'envoi | Il é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 vide | Le 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ême | L'é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 exception | La 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_required` | La 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 spam | Le 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

### Comment empêcher un formulaire de rafraîchir la page à l'envoi ?

Ajoutez un écouteur submit et appelez event.preventDefault() en première ligne, puis envoyez vous-même les données avec fetch. Écoutez l'événement submit du formulaire, pas le click du bouton, pour que la touche Entrée soit aussi couverte.

### Dois-je définir Content-Type quand j'envoie un FormData avec fetch ?

Non, et vous ne devriez pas. Le navigateur définit multipart/form-data avec la chaîne de délimitation. Un en-tête que vous écrivez vous-même n'a pas de délimitation, et le serveur ne peut pas analyser le corps.

### Le navigateur valide-t-il encore le formulaire si je l'envoie avec JavaScript ?

Oui, tant que vous écoutez l'événement submit, qui ne se déclenche qu'une fois la vérification des contraintes du navigateur réussie. Appeler form.submit() depuis le code ignore à la fois la vérification et l'événement ; form.requestSubmit() les exécute.

### Comment éviter les doubles envois ?

Ignorez les envois tant qu'une requête est en cours, et envoyez une clé d'idempotence par chargement de page, dans le champ _idempotency ou l'en-tête Idempotency-Key. Si une nouvelle tentative atteint quand même Ikiform, il rejoue la première réponse au lieu de stocker à nouveau la soumission.

## Autres guides

- [Envoi de formulaire HTML](https://ikiform.com/fr/guides/html-form-submission.md): Ce que fait un navigateur quand un formulaire est envoyé : quels champs il transmet, comment il les encode, ce que le serveur doit répondre, et comment Ikiform répond.
- [Validation de formulaire](https://ikiform.com/fr/guides/html-form-submission/validation.md): Validez un formulaire avec required, type, pattern et min, stylisez les erreurs avec :user-invalid, et imposez les mêmes règles sur le serveur avec des définitions de champs.
- [Redirection après envoi](https://ikiform.com/fr/guides/html-form-submission/redirect-after-submit.md): Envoyez les visiteurs vers votre propre page de remerciement après l'envoi d'un formulaire : la redirection 303, le champ _redirect, les domaines autorisés et l'effet d'un rafraîchissement.
- [L'attribut action](https://ikiform.com/fr/guides/html-form-submission/action-attribute.md): Ce que fait l'attribut action d'un formulaire, comment les valeurs relatives et vides sont résolues, comment formaction envoie un formulaire à deux endroits, et pourquoi les hébergeurs statiques répondent 405.
