Absenden von HTML-Formularen

Absenden von HTML-Formularen6 Min. Lesezeit

Ein HTML-Formular ohne Neuladen der Seite absenden

Sende ein Formular mit fetch und FormData, damit der Besucher auf der Seite bleibt, mit Feldfehlern, Ladezustand, Wiederholungen und einem Fallback, wenn Skripte versagen.

Preet SutharGründer, Ikiform

Horche auf das submit-Event des Formulars, rufe preventDefault() auf und sende new FormData(form) mit fetch und einem Accept: application/json-Header. Ikiform antwortet dann mit JSON statt einer Weiterleitung: 201 bei Erfolg oder Feldfehler, die du neben jedem Eingabefeld anzeigst. Behalte eine echte action und method am Formular, damit es weiterhin auf die einfache Art sendet, falls dein Skript nie läuft.

Das Beispiel hier ist eine Box Was this page helpful? (War diese Seite hilfreich?) am Ende einer Docs-Seite. Das ist der klassische Fall, um auf der Seite zu bleiben: Der Leser steckt mitten in einer Aufgabe, und ihn für einen einzigen Klick auf eine Dankeseite zu schicken, wäre absurd. Dasselbe Muster funktioniert für jedes Formular.

Was sich ändert, wenn du das Absenden übernimmst

Ein einfacher Formular-Post bringt dir viel gratis: Der Browser zeigt eine Ladeanzeige, macht Doppelklicks weitgehend harmlos, kümmert sich um den Zurück-Button und zeigt die Seite, die zurückkommt. Sobald du preventDefault() aufrufst, wird all das deine Aufgabe. Der fetch-Aufruf selbst sind drei Zeilen; der Rest dieser Seite ist die Arbeit, die vorher der Browser erledigt hat.

Was ein Skript tut, wenn es ein Formular mit fetch absendet, statt den Browser navigieren zu lassensubmit-Eventnach denPrüfungen desBrowserspreventDefault()keineNavigationfetch mitFormDataAccept:application/json201DankeszeileeinsetzenMeldung amFeldFokus wandertdorthin, Buttongeht wieder422 oder NetzwerkfehlerWas ein Skript tut, wenn es ein Formular mit fetch absendet, statt den Browser navigieren zu lassensubmit-Eventnach den Prüfungendes BrowserspreventDefault()keine Navigationfetch mitFormDataAccept:application/json201Dankeszeile einsetzenMeldungam FeldFokuswandertdorthin,Button gehtwieder422 oder Netzwerkfehler
  1. Der Browser führt zuerst seine Constraint-Prüfung aus. Das submit-Event wird nur ausgelöst, wenn das Formular gültig ist.
  2. Dein Listener ruft preventDefault auf, damit der Tab nicht navigiert.
  3. Er sendet die Daten des Formulars mit fetch und fordert JSON an.
  4. Bei 201 ersetzt er das Formular durch eine Statusmeldung und setzt den Fokus darauf.
  5. Bei einem 422 oder einem Netzwerkfehler zeigt er die Meldung neben dem Feld, fokussiert es und aktiviert das Senden wieder.
Die Validierung des Browsers läuft weiterhin vor deinem Code. Alles nach preventDefault liegt bei dir.
Das hat der Browser getanJetzt muss dein Skript
Zeigte einen Ladezustand im TabEinen anzeigen und Screenreadern ansagen
Machte Doppelklicks weitgehend harmlosEin zweites Absenden ignorieren, solange eines läuft
Zeigte die Fehlerseiteerror.fields jedem Eingabefeld zuordnen
Zeigte eine Netzwerk-FehlerseiteFehlgeschlagene Requests und Timeouts abfangen
Setzte den Fokus auf die neue SeiteFokus auf das Ergebnis oder das erste fehlerhafte Feld setzen

Das Formular

Drei Felder: ein Pflichtfeld Ja oder Nein, ein optionaler Kommentar und die Seitenadresse, die das Skript ausfüllt. Probier die Definition unten aus; Ikiform setzt sie auf dem Server durch, also bekommt ein Kommentar über 2.000 Zeichen auch hier einen 422.

Vorschau

Führt den Validator von Ikiform in deinem Browser aus. Es wird nichts gesendet.

War diese Seite hilfreich?
Füge das im Tab Fields des Formulars ein. Mit definierten Feldern lehnt Ikiform außerdem jedes Feld ab, das es nicht kennt.
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>

Das Formular ist ohne das Skript vollständig. Ist JavaScript aus oder blockiert, sendet es auf dem einfachen Weg, Ikiform antwortet mit 303, und der Leser landet auf der _redirect-Seite. Das Skript verbessert das nur.

Das Skript

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) liest jedes benannte Element, plus name und value des geklickten Buttons, falls er welche hat. Baue es, bevor du irgendetwas im Formular änderst.
  • Der Button bekommt aria-disabled, nicht disabled. Das Flag sending übernimmt das eigentliche Blockieren, und der Button bleibt fokussierbar, sodass Screenreader-Nutzer ihre Position nicht verlieren.
  • Kein Content-Type-Header. Mit einem FormData-Body setzt der Browser multipart/form-data mit der richtigen Boundary.
  • Accept: application/json lässt Ikiform in JSON antworten. Ein fetch-Aufruf sendet Sec-Fetch-Mode: cors, was ohnehin JSON wählen würde, aber der Header macht die Absicht deutlich.
  • Ein Idempotency Key pro Seitenaufruf, gesendet als Feld _idempotency. Sendet der Leser ab, verliert das Netzwerk die Antwort und sendet er erneut, gibt Ikiform die erste Antwort wieder, statt eine zweite Einsendung zu speichern. Ein abgelehnter Post beansprucht den Key nie, also geht ein korrigiertes erneutes Senden durch. Der Header Idempotency-Key tut dasselbe, aber ein eigener Header lässt den Browser zuerst einen Preflight-Request senden. JavaScript und fetch enthält die genauen Regeln.
  • AbortSignal.timeout(15000) macht aus einem hängenden Request den catch-Zweig statt eines endlosen Sending....
  • Die Statuszeile hat role="status", also wird ihr Text vorgelesen, ohne den Fokus zu verschieben. Bei Erfolg wandert der Fokus dorthin, weil die Felder drumherum weg sind.

Wenn das Formular eine Menschlichkeitsprüfung hat

Ist der Spam check des Formulars auf Ikiform gesetzt, bekommt ein fetch-Post ohne Turnstile-Token 403 challenge_required. Das Skript oben behandelt das, indem es form.submit() aufruft. Diese Methode überspringt den Submit-Listener und sendet auf dem einfachen Weg, und bei einem einfachen Post zeigt Ikiform seine eigene kurze Prüfseite und geht dann weiter zu deiner _redirect-Seite. Der Leser verlässt die Seite nur, wenn die Prüfung eingeschaltet ist.

Um auch dann auf der Seite zu bleiben, hol dir zuerst ein Token mit challenge(formId) aus dem SDK und übergib es an submit(). Die Spam-Doku zeigt den Ablauf.

Die Alternative ohne JavaScript

Vor fetch bestand der Trick darin, ein verstecktes iframe als Ziel zu setzen, damit die Antwort dort lädt und die Seite bleibt, wo sie ist:

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

Das funktioniert noch, aber das iframe enthält eine Seite auf ikiform.com, und der Browser lässt dein Skript kein Cross-Origin-Frame lesen. Du kannst Erfolg nicht von einem Validierungsfehler unterscheiden, also bekommt der Leser in keinem Fall Rückmeldung. Nutze es nur, wo ein Skript wirklich nicht laufen kann.

Ein Server-Framework kann dir Senden ohne Neuladen geben, ganz ohne fetch-Code im Client: Next.js Server Actions, use:enhance in SvelteKit und <Form> in React Router senden vor der Hydration auf dem einfachen Weg und danach ohne Neuladen. Die Übersicht zeigt jede Variante.

Prüfen, ob es funktioniert

  1. Füge unter Allowed domains des Formulars die Domain deiner Site hinzu, plus localhost, während du entwickelst. Lass das Feld leer, um Posts von überall anzunehmen.
  2. Sende Yes ohne Kommentar ab. Das Formular wird zu Thanks, that helps., und das Network-Panel zeigt 201.
  3. Füge mehr als 2.000 Zeichen in den Kommentar ein, entferne maxlength in den Entwicklertools und sende ab. Die Meldung erscheint unter dem Kommentar, und der Fokus wandert dorthin.
  4. Doppelklicke auf Send. Das Dashboard zeigt eine Einsendung, nicht zwei.
  5. Schalte JavaScript aus und sende ab. Du landest auf der _redirect-Seite.
  6. Setze die Drosselung auf Offline und sende ab. Die Statuszeile sagt, dass der Server nicht erreichbar war, und Send funktioniert wieder.

Stolperfallen

ProblemUrsache und Lösung
Ein Feld fehlt im PostEs war disabled, als FormData gebaut wurde. Deaktivierte Elemente werden nie gesendet. Baue die Daten zuerst oder sperre Inputs mit readonly
400 invalid_body oder eine leere EinsendungDer Code hat Content-Type: multipart/form-data von Hand gesetzt, ohne Boundary. Lösche den Header
Die Seite lädt trotzdem neuDer Listener hat vor preventDefault() einen Fehler geworfen, oder der Klick kam, bevor das Skript geladen war. Ruf zuerst preventDefault() auf; ein früher Klick sendet auf dem einfachen Weg, was trotzdem funktioniert
response.json() wirft einen FehlerDer Request ging an eine Adresse, die HTML geantwortet hat, meist eine relative action. Lies form.action in der Konsole; es muss deine Ikiform-URL sein
403 challenge_requiredDie Menschlichkeitsprüfung des Formulars ist an. Weiche wie oben auf form.submit() aus oder hol dir ein Token mit challenge()
403 origin_not_allowed, oder jeder Post landet im SpamDie Domain der Seite steht nicht unter Allowed domains des Formulars. Füge sie hinzu, plus localhost für lokale Tests. Subdomains einer eingetragenen Domain sind erlaubt.

Fragen

Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.