Envío de formularios HTML

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.

Preet SutharFundador, Ikiform

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.

Qué hace un script cuando envía un formulario con fetch en lugar de dejar que el navegador navegueeventosubmittras lascomprobacionesdel propionavegadorpreventDefault()sin navegaciónfetch conFormDataAccept:application/json201cambiar por unalínea deagradecimientomensaje en elcampoel foco va ahí,el botón vuelvea funcionar422 o error de redQué hace un script cuando envía un formulario con fetch en lugar de dejar que el navegador navegueevento submittras lascomprobaciones delpropio navegadorpreventDefault()sin navegaciónfetch conFormDataAccept:application/json201cambiar por una líneade agradecimientomensaje enel campoel foco vaahí, el botónvuelve afuncionar422 o error de red
  1. El navegador ejecuta primero su comprobación de restricciones. El evento submit solo se dispara cuando el formulario es válido.
  2. Tu listener llama a preventDefault, así que la pestaña no navega.
  3. Envía los datos del formulario con fetch y pide JSON.
  4. Con un 201, sustituye el formulario por un mensaje de estado y mueve el foco a él.
  5. 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.
La validación del navegador sigue ejecutándose antes de tu código. Todo lo que hay después de preventDefault lo gestionas tú.
El navegador hacía estoAhora tu script debe
Mostraba un estado de carga en la pestañaMostrar uno y anunciarlo a los lectores de pantalla
Hacía que los dobles clics fueran casi inofensivosIgnorar un segundo envío mientras hay uno en curso
Mostraba la página de errorAsociar error.fields a cada input
Mostraba una página de error de redCapturar peticiones fallidas y tiempos de espera agotados
Movía el foco a la nueva páginaMover 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.

¿Te ha sido útil esta página?
Pega esto en la pestaña Fields del formulario. Con campos definidos, Ikiform también rechaza cualquier campo que no conozca.
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>

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

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) lee cada control con nombre, más el name y el value del botón pulsado si los tiene. Créalo antes de cambiar nada en el formulario.
  • El botón recibe aria-disabled, no disabled. La variable sending hace 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 cuerpo FormData, el navegador pone multipart/form-data con el separador correcto.
  • Accept: application/json hace que Ikiform responda en JSON. Una llamada fetch envía Sec-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 cabecera Idempotency-Key hace 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:

Truco antiguo
<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.

Un framework de servidor puede darte envío sin recarga sin código fetch en el cliente: las server actions de Next.js, el 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

  1. En Allowed domains del formulario, añade el dominio de tu sitio, y localhost mientras desarrollas. Déjalo vacío para aceptar envíos desde cualquier lugar.
  2. Envía Sí sin comentario. El formulario se convierte en Gracias, nos ayuda. y el panel Network muestra 201.
  3. Pega más de 2.000 caracteres en el comentario, quita maxlength en las herramientas de desarrollo y envía. El mensaje aparece bajo el comentario y el foco se mueve ahí.
  4. Haz doble clic en Enviar. El panel muestra un envío, no dos.
  5. Desactiva JavaScript y envía. Llegas a la página _redirect.
  6. 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

ProblemaCausa y solución
Falta un campo en el envíoEstaba 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íoEl código puso Content-Type: multipart/form-data a mano, sin separador. Borra la cabecera
La página se recarga igualmenteEl 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 errorLa 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_requiredLa 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 spamEl 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

¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.