Menú de navegaciónMenú
Categorías

La mejor forma de Aprender Programación online y en español www.campusmvp.es

?id=9feb9b5d-3099-4334-b0b2-ca5e47680135

HTML / JavaScript: Cómo evitar que un formulario se envíe varias veces

Imagen ornamental

Imagina que estás comprando algo en línea y, después de completar todo el formulario con tus datos, haces clic en el botón de Enviar pedido. La página se queda cargando un rato, así que impaciente, vuelves a hacer clic en el mismo botón porque parece que no ha funcionado... y de repente recibes una notificación de que tu pedido se ha duplicado 😵‍💫

Es un caso muy extremo, claro, pero este es un problema común al trabajar con formularios web: el temido envío múltiple. Ocurre cuando un usuario, creyendo que la primera solicitud no se procesó correctamente (o porque es de la no tan rara especie de los que hace doble-clic a todo), vuelve a enviar el formulario. El resultado puede ser desde duplicados inofensivos hasta acciones críticas ejecutadas varias veces, lo que podría causar un verdadero lío.

Afortunadamente, existen varias técnicas que podemos implementar en nuestras aplicaciones web para evitar este molesto problema. En este post, vamos a explorar diferentes enfoques para prevenir el envío múltiple de formularios HTML, desde soluciones sencillas en el lado del cliente hasta validaciones más robustas en el lado del servidor.

¡Vamos a evitar esos envíos duplicados de una vez por todas!

Técnica 1 (Front): Deshabilitar el botón de envío

Una de las formas más sencillas de evitar el envío múltiple de un formulario es deshabilitar el botón de envío después de que el usuario haya hecho clic en él por primera vez. De esta manera, incluso si el usuario vuelve a hacer clic frenéticamente en el botón, no sucederá nada.

Implementar esta solución en JavaScript es bastante directo. Primero, cuando la página haya cargado (en el evento DOMContentLoaded), obtendremos una referencia al botón de envío y al formulario en sí. Luego, agregamos un controlador de eventos al botón para deshabilitar el botón después de que se haya hecho clic en él:

// Obtener referencias al formulario y botón de envío
const form = document.getElementById('miFormulario');
//Si no es un botón submitr se usaría un selector diferente, claro
const submitButton = document.querySelector('button[type="submit"]');

// Agregar controlador de eventos al botón de envío
submitButton.addEventListener('click', (event) => {
  // Deshabilitar el botón de envío
  event.preventDefault(); // Evitar el envío del formulario
  submitButton.disabled = true; // Deshabilitar el botón

  // Enviar el formulario
  form.submit();
});

En este ejemplo, primero prevenimos el comportamiento predeterminado del botón de envío con event.preventDefault(). Luego, deshabilitamos el botón estableciendo submitButton.disabled = true. Finalmente, enviamos el formulario manualmente llamando a form.submit().

Puedes incluso cambiar el texto del botón después de deshabilitarlo para dejar claro que la acción ya se ha realizado. Por ejemplo con: submitButton.textContent = '¡Enviando! 🚀'.

Esta solución simple funciona bien para la mayoría de los casos, pero tiene varias desventajas:

  • Si el usuario actualiza la página después de enviar el formulario, el botón volverá a habilitarse. Para evitar esto, puedes deshabilitar el botón en el servidor después de procesar el formulario y volver a habilitar el botón solo cuando se muestre un formulario en blanco.
  • Si estás usando un botón de tipo "submit" el formulario también se enviará cuando el usuario pulse ENTER al tener el foco cualquier cuadro de texto del formulario. Por ello, deberías usar otro tipo de botón (un <button> sin más, por ejemplo) o bien capturar también el evento submit del formulario para gestionar el deshabilitar el botón.

Consejo importante: aunque esto funciona y puede ser suficiente para muchos casos, debes tener claro que las medidas que tomes solamente en el lado del cliente, con JavaScript, siempre van a tener muchas vías para poder saltárselas. Una validación robusta en Front-End siempre implica hacerla en el lado de servidor, además de en el lado cliente (en el navegador). Ello implicará generalmente utilizar varias de las técnicas que presentamos en este artículo.

Técnica 2 (Front): Bloqueo del formulario

Otra técnica para evitar el envío múltiple de formularios, parecida a la anterior pero más ambiciosa, es implementar un bloqueo del lado del cliente utilizando JavaScript. Esto implica bloquear completamente el formulario después de su primer envío, impidiendo que los usuarios interactúen con él hasta que se haya completado el procesamiento en el servidor.

Aquí está un ejemplo de cómo podemos lograrlo:

// Obtener referencias al formulario y botón de envío
// Es lo mismo de antes
const form = document.getElementById('miFormulario');
const submitButton = document.querySelector('button[type="submit"]');

// Agregar controlador de eventos al envío del formulario
form.addEventListener('submit', (event) => {
  // Prevenir el envío predeterminado del formulario
  event.preventDefault();

  // Deshabilitar todos los campos del formulario
  //Se podrían simplemente recorrer todos y no buscar solo estos tipos
  form.querySelectorAll('input, textarea, button, select').forEach(field => {
    field.disabled = true;
  });

  // Cambiar el texto del botón de envío a "Enviando..."
  submitButton.textContent = 'Enviando...';

  // Enviar el formulario mediante AJAX
  const formData = new FormData(form);
  fetch('/enviar-formulario', {
    method: 'POST',
    body: formData
  })
  .then(response => {
    if (response.ok) {
      // Formulario enviado correctamente
      form.reset(); // Restablecer el formulario
      //Esto es un ejemplo: no deberíamos usar alert!
      alert('¡Formulario enviado!');
    } else {
      // Ocurrió algo raro
      alert('Algo ha pasado al enviar el formulario');
    }
  })
  .catch(error => {
    // Manejar errores de red
    alert('Error de red: ' + error);
  })
  .finally(() => {
    // Habilitar nuevamente los campos del formulario
    form.querySelectorAll('input, textarea, button, select').forEach(field => {
      field.disabled = false;
    });
    submitButton.textContent = 'Enviar'; // Restablecer el texto del botón
  });
});

En este ejemplo, cuando se envía el formulario, primero prevenimos el comportamiento predeterminado utilizando event.preventDefault(). Luego, deshabilitamos todos los campos del formulario para que el usuario no pueda interactuar con ellos.

A continuación, enviamos el formulario mediante una solicitud AJAX y o directamente por parte de la página, utilizando la API fetch de JavaScript. Mientras esperamos la respuesta del servidor, el botón de envío muestra el texto "Enviando...".

Si el envío es exitoso, restablecemos el formulario y mostramos un mensaje de confirmación. Si ocurre un error, lo manejamos y mostramos un mensaje correspondiente (alert no es la mejor idea, pero es un ejemplo, claro).

Finalmente, en el bloque finally, habilitamos nuevamente todos los campos del formulario y restablecemos el texto del botón de envío a "Enviar".

Esta técnica tiene la ventaja de proporcionar una experiencia de usuario más fluida, ya que el formulario se bloquea mientras se está procesando, evitando cualquier posibilidad de envío múltiple. Además, al utilizar AJAX, no es necesario recargar la página completa después de enviar el formulario.

Consejo: cuando se envía un formulario, sea directamente o como en este caso en segundo plano, es recomendable mostrar una indicación visual de carga, como un spinner o una barra de progreso. Esto le da al usuario un feedback claro de que el formulario se está procesando y reduce la tentación de volver a hacer clic en el botón de envío.

Técnica 3 (Front): Redirección después del envío exitoso

Otra estrategia efectiva para evitar el envío múltiple de formularios es redirigir al usuario a otra página después de que el formulario se haya enviado exitosamente. Esto no solo proporciona una confirmación visual al usuario de que el formulario se ha enviado correctamente, sino que también evita que pueda volver a enviar el formulario al recargar la página o hacer clic nuevamente en el botón de envío.

A continuación, puedes ver un ejemplo de cómo implementar esta solución utilizando JavaScript:

document.addEventListener("DOMContentLoaded", function() {
    // Capturamos una referncia al formulario
    var form = document.getElementById('miFormulario');

    // A la hora de enviar el formulario...
    form.addEventListener("submit", function(event) {
      // ...redirigimos al usuario a otra página
      window.location.href = "exito.html";
      
      // cancelamos el envío del formulario para evitar que se procese
      event.preventDefault();
      
      // Enviamos el formulario mediante AJAX
      //Mismo código que en el apartado anterior (no se repite aquí)
    });
  });

En este ejemplo, hemos utilizado JavaScript para agregar un evento al envío del formulario. Cuando el formulario se envía, redirigimos al usuario a otra página (en este caso, "exito.html") utilizando window.location.href. Esto evita que el usuario pueda enviar el formulario nuevamente al recargar la página o hacer clic nuevamente en el botón de envío.

Esta solución proporciona una forma clara y efectiva de evitar el envío múltiple de formularios al redirigir al usuario a otra página después del envío exitoso, garantizando (o casi) una experiencia de usuario fluida y sin errores.

Nota: esta técnica complementa bien a las anteriores (todas de Front-End) y se puede usar en combinación con estas y siempre alguna del lado servidor (Back-End).

Técnica 4 (Back): Utilizar tókenes de única vez

Si bien las técnicas del lado del cliente son útiles para mejorar la experiencia del usuario y proporcionar una capa adicional de protección, es fundamental implementar validaciones en el lado del servidor para evitar el envío múltiple de formularios de manera confiable.

Una técnica de lado servidor, efectiva pero más compleja, para prevenir el envío múltiple de formularios es utilizar tókenes de única vez (one-time tokens). Esta implica generar un token aleatorio en el servidor y enviarlo al cliente junto con el formulario. Posteriormente, cuando se envía el formulario, el servidor verifica que el token recibido sea válido, y lo marca como utilizado para evitar envíos posteriores con el mismo token.

Aquí está un ejemplo básico de cómo implementar esta solución:

Nota: se ha utilizado código JavaScript tanto en cliente como en servidor (Node.js) por simplicidad, pero implementar algo así en cualquier lenguaje (.NET, Java, PHP, Python....) es bastante sencillo en cualquier caso y todas las plataformas poseen múltiples métodos para crear los tókenes.

1.- Generar el token en el servidor

En el servidor, cuando se carga la página que contiene el formulario, generamos un token aleatorio y lo almacenamos en la sesión del usuario (o en una caché temporal):

const crypto = require('crypto');

app.get('/formulario', (req, res) => {
  const token = crypto.randomBytes(16).toString('hex');
  req.session.formToken = token;
  res.render('formulario', { token });
});

Aquí, estamos utilizando el módulo crypto de Node.js para generar un token aleatorio de 16 bytes y convertirlo a una cadena hexadecimal. El formulario se renderiza a partir de una plantilla.

2.- Incluir el token en el formulario

En el HTML del formulario, incluimos un campo oculto que contiene el token generado:

<form method="POST" action="/enviar-formulario">
    <!-- Otros campos del formulario -->
    <input type="hidden" name="token" value="{{ token }}">
    <button type="submit">Enviar</button>
</form>

3.- Validar el token en el servidor

Cuando el formulario se envía al servidor, verificamos que el token enviado sea válido y lo eliminamos de la sesión (o caché) para prevenir envíos posteriores con el mismo token.

app.post('/enviar-formulario', (req, res) => {
  const token = req.body.token;
  if (token !== req.session.formToken) {
    // Token inválido, abortar
    return res.status(400).send('Token inválido');
  }

  // Procesar el formulario
  // ...

  // Eliminar el token de la sesión
  delete req.session.formToken;

  res.send('Formulario enviado correctamente');
});

Se puede combinar con la anterior, de lado cliente, por usabilidad.

Esta técnica es efectiva y segura, ya que los tókenes son aleatorios y difíciles de predecir. Sin embargo, tiene algunas desventajas: requiere almacenar los tókenes en el servidor (ya sea en sesión, en caché o en otro lado) y puede ser complicada de implementar en aplicaciones más complejas con múltiples flujos de formularios.

Consejo: si se rechaza un envío duplicado, asegúrate de proporcionar una retroalimentación clara al usuario sobre lo que sucedió y qué acción debe tomar. Esto mejora la experiencia del usuario y reduce la confusión.

Técnica 5 (Back): Bloqueo distribuido

Una de las técnicas más efectivas para prevenir el envío múltiple de formularios en el lado del servidor es implementar un bloqueo distribuido utilizando una herramienta como Redis. Esta solución es especialmente útil en entornos con múltiples instancias (clústeres), donde varias instancias de la aplicación podrían estar procesando solicitudes en paralelo.

Redis es una popular y rapidísima base de datos en memoria que, además de su uso principal como caché y almacén de datos, también ofrece funciones de bloqueo distribuido. Se podría usar algún sistema alternativo a Redis, pero esta base de datos es sin duda la más popular para hacer de caché por lo que es probable que en una aplicación grande la tengas en operación.

Veamos un ejemplo de cómo podríamos implementar un bloqueo distribuido con Redis para evitar el envío múltiple de formularios, en este caso en una aplicación Node.js (pero sería muy similar en cualquier otra plataforma):

const redis = require('redis');
const client = redis.createClient();

// Ruta para procesar el formulario
app.post('/enviar-formulario', async (req, res) => {
  const { nombre, email, mensaje } = req.body;

  // Adquirir un bloqueo para evitar procesamiento concurrente
  const lockKey = `formulario_lock:${email}`;
  const lock = await acquireLock(client, lockKey); //Función definida más abajo

  if (!lock) {
    // No se pudo adquirir el bloqueo, rechazar la solicitud
    return res.status(429).send('Demasiadas solicitudes. Inténtalo de nuevo más tarde.');
  }

  //Si no hay un bloque previo...
  try {
    // Procesamos el formulario (insertar en la base de datos, enviar correo electrónico, etc...)
    await db.insertOne({ nombre, email, mensaje });
    await enviarCorreoElectronico(email, mensaje);

    res.send('Formulario enviado correctamente');
  } catch (error) {
    res.status(500).send('Ocurrió un error en el servidor');
  } finally {
    // Liberar el bloqueo. Función definida debajo
    await releaseLock(client, lockKey, lock);
  }
});

// Función para adquirir un bloqueo distribuido
async function acquireLock(client, lockKey, retry = 3, delay = 200) {
  const lock = await client.set(lockKey, 'locked', 'NX', 'EX', 10);

  if (lock === 'OK') {
    return lock;
  } else if (retry > 0) {
    await new Promise(resolve => setTimeout(resolve, delay));
    return await acquireLock(client, lockKey, retry - 1, delay * 2);
  }

  return null;
}

// Función para liberar un bloqueo distribuido
async function releaseLock(client, lockKey, lock) {
  if (lock === 'OK') {
    return await client.del(lockKey);
  }
}

En este ejemplo, estamos utilizando Redis para adquirir un bloqueo distribuido antes de procesar el formulario. El bloqueo se basa en una clave única construida a partir del email del usuario (formulario_lock:${email}). Esto nos permite bloquear el procesamiento concurrente del mismo formulario para un usuario específico pero, dependiendo de tu caso de uso concreto seguramente necesites usar otro identificador único diferente, atado a la operación.

La función acquireLock intenta adquirir el bloqueo utilizando el comando SET de Redis con las opciones NX (solo si no existe) y EX (expiración automática, en este caso después de 10 segundos). Si se adquiere el bloqueo correctamente, se devuelve 'OK'. Si no se puede adquirir el bloqueo, se realiza un reintento con un retraso exponencial (se multiplica por 2 en cada reintento).

Si el bloqueo no existe, procesamos el formulario (insertando datos en la base de datos, enviando un correo electrónico, etc.).

Finalmente, liberamos el bloqueo utilizando la función releaseLock en el bloque finally para garantizar que se libere incluso si ocurre un error.

Esta técnica es un poco "matar moscas a cañonazos" para la mayoría de las aplicaciones que podrás hacer, pero si tu aplicación crece y al final necesita gestionar varios nodos que atienden peticiones simultáneamente, utilizar un bloqueo distribuido con Redis o un mecanismo similar nos permite garantizar que solo una instancia de la aplicación procese un formulario específico en un momento dado, evitando efectivamente el envío múltiple.

Consejo: además, Redis es altamente escalable y proporciona un rendimiento excepcional para operaciones de bloqueo, lo que lo convierte en una gran elección para aplicaciones con alto tráfico.

Conclusión

En este artículo hemos explorado varias técnicas y buenas prácticas para evitar el molesto problema del envío múltiple de formularios HTML. Desde soluciones sencillas en el lado del cliente, como deshabilitar el botón de envío o implementar un bloqueo del formulario, hasta validaciones más robustas en el lado del servidor, utilizando tókenes de única vez e incluso, para aplicaciones grandes, un mecanismo de bloqueo distribuido.

Cada enfoque tiene sus ventajas y desafíos, y la elección de la mejor solución dependerá de los requisitos concretos de tu aplicación. De todos modos es importante que no te olvides de que, si quieres una protección sólida contra el envío múltiple de formularios, deberás usar al menos una técnica de lado cliente (Front) y alguna de lado servidor (Back).

Prevenir el envío múltiple de formularios no solo es importante para garantizar la integridad de los datos y evitar problemas potenciales, sino que también puede mejorar significativamente la experiencia del usuario. Nadie quiere sentir frustración al intentar completar un formulario y encontrarse con resultados inesperados o duplicados.

Así que, sea cual sea el tipo de aplicación web que estés creando, si involucra formularios asegúrate de tener en cuenta las técnicas que hemos cubierto en este artículo. Tus usuarios (y tus datos) te lo agradecerán.

 

José M. Alarcón Aguín Fundador de campusMVP, es ingeniero industrial y especialista en consultoría de empresa. Ha escrito diversos libros, habiendo publicado hasta la fecha cientos de artículos sobre informática e ingeniería en publicaciones especializadas. Microsoft lo ha reconocido como MVP (Most Valuable Professional) en desarrollo web desde el año 2004 hasta la actualidad. Puedes seguirlo en Twitter en @jm_alarcon o leer sus blog técnico o personal. Ver todos los posts de José M. Alarcón Aguín
Archivado en: Desarrollo Web

Boletín campusMVP.es

Solo cosas útiles. Una vez al mes.

🚀 Únete a miles de desarrolladores

DATE DE ALTA

x No me interesa | x Ya soy suscriptor

La mejor formación online para desarrolladores como tú

Comentarios (3) -

Muy buen aporte, ya había generado mi propio web de una vez en php, aún no lo he probado en múltiples instancias.
Gracias!!

Responder

Hola, muy buen post, en la técnica 4, suponiendo que es PHP y que no se envía un header redireccion al navegador después del envio.Si el usuario actualiza la página tendría un nuevo token, este se podría en sesión y podría enviar las veces que quiera. Si no actualiza funcionaria bien, corregirme si estoy equivocado, en todo caso que solución abría usar la redirección?.

Responder

José M. Alarcón Aguín
José M. Alarcón Aguín

Hola. Gracias, me alegro de que te guste. En realidad lo normal sería eliminar también el token de la sesión una vez recibido el formulario, aunque no está puesto en el ejemplo, y así no se podría reutilizar. En caso de no hacer redirección y dejarlo en el mismo formulario (aunque esto no es muy buena idea desde el punto de vista de usabilidad), cada vez que se recargue la página del formulario deberías generar un nuevo token. De todos modos no sé si te estoy entiendo bien.

Saludos!

Responder

Agregar comentario

Los datos anteriores se utilizarán exclusivamente para permitirte hacer el comentario y, si lo seleccionas, notificarte de nuevos comentarios en este artículo, pero no se procesarán ni se utilizarán para ningún otro propósito. Lee nuestra política de privacidad.