Envío de formularios HTML7 min de lectura
Enviar un formulario HTML sin recargar la página
Envía un formulario con fetch y FormData para que el visitante se quede en la página, con errores de campo, estado de carga, reintentos y un plan B cuando los scripts fallan.
Escucha el evento submit del formulario, llama a preventDefault() y envía new FormData(form) con fetch y una cabecera Accept: application/json. Ikiform responde entonces con JSON en lugar de una redirección: 201 si todo va bien, o errores de campo que muestras junto a cada input. Mantén un action y un method reales en el formulario para que siga enviándose de la forma normal si tu script nunca se ejecuta.
El ejemplo aquí es una caja Was this page helpful? al final de una página de documentación. Es el caso típico para quedarse en la página: el lector está a mitad de una tarea, y mandarlo a una página de agradecimiento para registrar un solo clic sería absurdo. El mismo patrón sirve para cualquier formulario.
Qué cambia cuando tomas el control del envío
Un envío de formulario normal te da mucho gratis: el navegador muestra un indicador de carga, hace que los dobles clics importen poco, gestiona el botón atrás y muestra la página que vuelva. En cuanto llamas a preventDefault(), todo eso pasa a ser cosa tuya. La llamada fetch en sí son tres líneas; el resto de esta página es el trabajo que antes hacía el navegador.
- El navegador ejecuta primero su comprobación de restricciones. El evento submit solo se dispara cuando el formulario es válido.
- Tu listener llama a preventDefault, así que la pestaña no navega.
- Envía los datos del formulario con fetch y pide JSON.
- Con un 201, sustituye el formulario por un mensaje de estado y mueve el foco a él.
- Con un 422 o un fallo de red, muestra el mensaje junto al campo, le da el foco y vuelve a habilitar el envío.
| El navegador hacía esto | Ahora tu script debe |
|---|---|
| Mostraba un estado de carga en la pestaña | Mostrar uno y anunciarlo a los lectores de pantalla |
| Hacía que los dobles clics fueran casi inofensivos | Ignorar un segundo envío mientras hay uno en curso |
| Mostraba la página de error | Asociar error.fields a cada input |
| Mostraba una página de error de red | Capturar peticiones fallidas y tiempos de espera agotados |
| Movía el foco a la nueva página | Mover el foco al resultado o al primer campo incorrecto |
El formulario
Tres campos: un sí o no obligatorio, un comentario opcional y la dirección de la página, que rellena el script. Prueba la definición de abajo; es lo que Ikiform aplica en el servidor, así que un comentario de más de 2.000 caracteres también recibe aquí un 422.
Vista previa
Ejecuta el validador de Ikiform en tu navegador. No se envía nada.
<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>El formulario está completo sin el script. Con JavaScript desactivado o bloqueado, se envía de la forma normal, Ikiform responde 303 y el lector llega a la página _redirect. El script solo lo mejora.
El script
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)lee cada control con nombre, más elnamey elvaluedel botón pulsado si los tiene. Créalo antes de cambiar nada en el formulario.- El botón recibe
aria-disabled, nodisabled. La variablesendinghace el bloqueo real, y el botón sigue pudiendo recibir el foco para que los usuarios de lectores de pantalla no pierdan su posición. - Sin cabecera
Content-Type. Con un cuerpoFormData, el navegador ponemultipart/form-datacon el separador correcto. Accept: application/jsonhace que Ikiform responda en JSON. Una llamadafetchenvíaSec-Fetch-Mode: cors, que elegiría JSON de todos modos, pero la cabecera deja clara la intención.- Una clave de idempotencia por carga de página, enviada como campo
_idempotency. Si el lector envía, la red pierde la respuesta y vuelve a enviar, Ikiform repite la primera respuesta en lugar de guardar un segundo envío. Un envío rechazado nunca consume la clave, así que un reenvío corregido sí entra. La cabeceraIdempotency-Keyhace lo mismo, pero una cabecera personalizada hace que el navegador envíe antes una petición de preflight. JavaScript y fetch tiene las reglas exactas. AbortSignal.timeout(15000)convierte una petición colgada en la rama catch en lugar de un Sending... interminable.- La línea de estado tiene
role="status", así que su texto se lee sin mover el foco. Si todo va bien, el foco se mueve a ella porque los campos que la rodeaban ya no están.
Cuando el formulario tiene verificación humana
Si el Spam check del formulario está en Ikiform, un envío con fetch sin token de Turnstile recibe 403 challenge_required. El script de arriba lo gestiona llamando a form.submit(). Ese método se salta el listener de submit y envía de la forma normal, y para un envío normal Ikiform muestra su propia página de verificación corta y luego continúa hasta tu página _redirect. El lector sale de la página solo cuando la verificación está activada.
Para quedarte en la página incluso entonces, obtén primero un token con challenge(formId) del SDK y pásalo a submit(). La documentación de spam muestra el flujo.
La alternativa sin JavaScript
Antes de fetch, el truco era apuntar a un iframe oculto, para que la respuesta se cargue ahí y la página no se mueva:
<iframe name="feedback-sink" hidden></iframe>
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post" target="feedback-sink">
...
</form>Sigue funcionando, pero el iframe contiene una página de ikiform.com, y el navegador no deja que tu script lea un frame de otro origen. No puedes distinguir un éxito de un error de validación, así que el lector tampoco recibe ninguna respuesta. Úsalo solo donde de verdad no pueda ejecutarse un script.
use:enhance de SvelteKit y el <Form> de React Router envían de la forma normal antes de que la página se hidrate y sin recarga después. El resumen muestra cada uno.Comprueba que funciona
- En Allowed domains del formulario, añade el dominio de tu sitio, y
localhostmientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar. - Envía Sí sin comentario. El formulario se convierte en Gracias, nos ayuda. y el panel Network muestra
201. - Pega más de 2.000 caracteres en el comentario, quita
maxlengthen las herramientas de desarrollo y envía. El mensaje aparece bajo el comentario y el foco se mueve ahí. - Haz doble clic en Enviar. El panel muestra un envío, no dos.
- Desactiva JavaScript y envía. Llegas a la página
_redirect. - Pon la limitación de red en Offline y envía. La línea de estado dice que no pudo contactar con el servidor, y Enviar vuelve a funcionar.
Errores comunes
| Problema | Causa y solución |
|---|---|
| Falta un campo en el envío | Estaba disabled cuando se creó el FormData. Los controles desactivados nunca se envían. Crea los datos primero, o bloquea los inputs con readonly |
400 invalid_body o un envío vacío | El código puso Content-Type: multipart/form-data a mano, sin separador. Borra la cabecera |
| La página se recarga igualmente | El listener lanzó un error antes de preventDefault(), o el clic llegó antes de que cargara el script. Llama a preventDefault() primero; un clic temprano envía de la forma normal, que sigue funcionando |
response.json() lanza un error | La petición fue a un sitio que respondió con HTML, normalmente por un action relativo. Lee form.action en la consola; debe ser tu URL de Ikiform |
403 challenge_required | La verificación humana del formulario está activada. Recurre a form.submit() como arriba, u obtén un token con challenge() |
403 origin_not_allowed, o todos los envíos acaban en spam | El dominio de la página no está en Allowed domains del formulario. Añádelo, y localhost para las pruebas locales. Los subdominios de un dominio de la lista están permitidos. |
Preguntas
Añade un listener de submit y llama a event.preventDefault() en su primera línea, y luego envía tú los datos con fetch. Escucha el evento submit del formulario, no el clic del botón, para cubrir también la tecla Enter.
No, y no deberías. El navegador pone multipart/form-data con la cadena separadora. Una cabecera que escribas tú no tiene separador, y el servidor no puede analizar el cuerpo.
Sí, siempre que escuches el evento submit, que solo se dispara después de que pase la comprobación de restricciones del navegador. Llamar a form.submit() desde código se salta tanto la comprobación como el evento; form.requestSubmit() ejecuta ambos.
Ignora los envíos mientras haya una petición en curso, y envía una clave de idempotencia por carga de página, como campo _idempotency o como cabecera Idempotency-Key. Si un reintento llega igualmente a Ikiform, repite la primera respuesta en lugar de guardar el envío otra vez.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.