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.
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.
- Der Browser führt zuerst seine Constraint-Prüfung aus. Das submit-Event wird nur ausgelöst, wenn das Formular gültig ist.
- Dein Listener ruft preventDefault auf, damit der Tab nicht navigiert.
- Er sendet die Daten des Formulars mit fetch und fordert JSON an.
- Bei 201 ersetzt er das Formular durch eine Statusmeldung und setzt den Fokus darauf.
- Bei einem 422 oder einem Netzwerkfehler zeigt er die Meldung neben dem Feld, fokussiert es und aktiviert das Senden wieder.
| Das hat der Browser getan | Jetzt muss dein Skript |
|---|---|
| Zeigte einen Ladezustand im Tab | Einen anzeigen und Screenreadern ansagen |
| Machte Doppelklicks weitgehend harmlos | Ein zweites Absenden ignorieren, solange eines läuft |
| Zeigte die Fehlerseite | error.fields jedem Eingabefeld zuordnen |
| Zeigte eine Netzwerk-Fehlerseite | Fehlgeschlagene Requests und Timeouts abfangen |
| Setzte den Fokus auf die neue Seite | Fokus 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.
<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
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, plusnameundvaluedes geklickten Buttons, falls er welche hat. Baue es, bevor du irgendetwas im Formular änderst.- Der Button bekommt
aria-disabled, nichtdisabled. Das Flagsendingübernimmt das eigentliche Blockieren, und der Button bleibt fokussierbar, sodass Screenreader-Nutzer ihre Position nicht verlieren. - Kein
Content-Type-Header. Mit einemFormData-Body setzt der Browsermultipart/form-datamit der richtigen Boundary. Accept: application/jsonlässt Ikiform in JSON antworten. Einfetch-Aufruf sendetSec-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 HeaderIdempotency-Keytut 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:
<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.
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
- 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. - Sende Yes ohne Kommentar ab. Das Formular wird zu Thanks, that helps., und das Network-Panel zeigt
201. - Füge mehr als 2.000 Zeichen in den Kommentar ein, entferne
maxlengthin den Entwicklertools und sende ab. Die Meldung erscheint unter dem Kommentar, und der Fokus wandert dorthin. - Doppelklicke auf Send. Das Dashboard zeigt eine Einsendung, nicht zwei.
- Schalte JavaScript aus und sende ab. Du landest auf der
_redirect-Seite. - Setze die Drosselung auf Offline und sende ab. Die Statuszeile sagt, dass der Server nicht erreichbar war, und Send funktioniert wieder.
Stolperfallen
| Problem | Ursache und Lösung |
|---|---|
| Ein Feld fehlt im Post | Es 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 Einsendung | Der Code hat Content-Type: multipart/form-data von Hand gesetzt, ohne Boundary. Lösche den Header |
| Die Seite lädt trotzdem neu | Der 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 Fehler | Der 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_required | Die 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 Spam | Die 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
Füge einen Submit-Listener hinzu und ruf event.preventDefault() als erste Zeile auf, dann sende die Daten selbst mit fetch. Lausche auf das submit-Event des Formulars, nicht auf den click des Buttons, damit auch die Enter-Taste abgedeckt ist.
Nein, und das solltest du auch nicht. Der Browser setzt multipart/form-data mit dem Boundary-String. Ein selbst geschriebener Header hat keine Boundary, und der Server kann den Body nicht parsen.
Ja, solange du auf das submit-Event lauschst, das erst feuert, nachdem die Constraint-Prüfung des Browsers bestanden ist. Ein Aufruf von form.submit() im Code überspringt sowohl die Prüfung als auch das Event; form.requestSubmit() führt beide aus.
Ignoriere Absendungen, solange ein Request läuft, und sende einen Idempotency Key pro Seitenaufruf, als Feld _idempotency oder als Header Idempotency-Key. Erreicht ein erneuter Versuch Ikiform trotzdem, gibt es die erste Antwort wieder, statt die Einsendung erneut zu speichern.
Hängst du bei einem Schritt fest oder willst eine Anleitung für deinen Fall? Schreib an hello@ikiform.com.