Cómo crear un formulario HTML con validación del lado del cliente en JavaScript

Programmazione JavaScript
Programmazione JavaScript

La validación del lado del cliente sirve para mejorar la experiencia del usuario: señala errores de inmediato, reduce envíos innecesarios y hace el formulario más «utilizable». Pero no es una medida de seguridad: cada control debe repetirse en el lado del servidor. Dicho esto, una validación del lado del cliente bien hecha es uno de esos detalles que realmente cambian las conversiones (y reducen los contactos «sucios»).

En esta guía veremos cómo crear un formulario HTML moderno con validación del lado del cliente en JavaScript, utilizando las herramientas nativas del navegador (Constraint Validation API) y añadiendo mensajes personalizados, gestión de errores, accesibilidad y una UX limpia.

¿Qué significa «validación del lado del cliente»?

Validación del lado del cliente significa verificar los datos antes de que el formulario se envíe al servidor. Ejemplos:

  • campo de email vacío o no válido
  • contraseña demasiado corta
  • checkbox «acepto la privacidad» no seleccionada
  • número de teléfono con formato incorrecto

La mejor manera de hacerlo es combinar:

  • validación HTML5 (required, type, minlength, pattern, etc.)
  • JavaScript para gestionar la UI, mensajes personalizados y lógica adicional

Regla de oro: UX del lado del cliente, seguridad del lado del servidor

Recuerda: el usuario puede deshabilitar JS, modificar el HTML o enviar solicitudes directamente vía API. Por lo tanto:

  • la validación del cliente sirve para UX y calidad de datos
  • la validación del servidor sirve para seguridad e integridad

Ejemplo completo: formulario con validación y mensajes personalizados

A continuación encuentras un ejemplo listo: nombre, email, teléfono, mensaje y consentimiento de privacidad. Usamos:

  • Constraint Validation API (checkValidity, reportValidity, validity)
  • mensajes de error para cada campo individual
  • clases CSS para estado válido/no válido
  • accesibilidad con aria-describedby y aria-live
<form id="contactForm" novalidate>
  <div class="field">
    <label for="name">Nombre y apellidos</label>
    <input id="name" name="name" type="text" required minlength="2"
           autocomplete="name" aria-describedby="nameError">
    <p id="nameError" class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required
           autocomplete="email" aria-describedby="emailError">
    <p id="emailError" class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label for="phone">Teléfono (opcional)</label>
    <input id="phone" name="phone" type="tel"
           inputmode="tel"
           pattern="^\+?[0-9\s\-]{7,20}$"
           placeholder="+34 600 123 456"
           autocomplete="tel" aria-describedby="phoneHelp phoneError">
    <small id="phoneHelp" class="help">Formato admitido: números, espacios y guiones.</small>
    <p id="phoneError" class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label for="message">Mensaje</label>
    <textarea id="message" name="message" required minlength="10" rows="5"
              aria-describedby="messageError"></textarea>
    <p id="messageError" class="error" aria-live="polite"></p>
  </div>

  <div class="field">
    <label class="checkbox">
      <input id="privacy" name="privacy" type="checkbox" required aria-describedby="privacyError">
      He leído y acepto la política de privacidad
    </label>
    <p id="privacyError" class="error" aria-live="polite"></p>
  </div>

  <button type="submit">Enviar</button>

  <p id="formStatus" class="status" aria-live="polite"></p>
</form>

Estilos mínimos (opcionales) para que los estados sean claros:

.field { margin-bottom: 14px; }
label { display:block; margin-bottom: 6px; font-weight: 600; }
input, textarea { width: 100%; padding: 10px; border: 1px solid #ccc; border-radius: 8px; }
input.is-invalid, textarea.is-invalid { border-color: #c0392b; }
input.is-valid, textarea.is-valid { border-color: #27ae60; }
.error { margin: 6px 0 0; color: #c0392b; font-size: 0.95rem; }
.help { display:block; margin-top: 6px; color:#666; font-size: 0.9rem; }
.status { margin-top: 12px; }

Y ahora la parte de JavaScript: validación al enviar + validación «en vivo» al escribir.

(() => {
  const form = document.getElementById('contactForm');
  const statusEl = document.getElementById('formStatus');

  // Mapa campo -> elemento de error
  const errorMap = new Map([
    ['name', document.getElementById('nameError')],
    ['email', document.getElementById('emailError')],
    ['phone', document.getElementById('phoneError')],
    ['message', document.getElementById('messageError')],
    ['privacy', document.getElementById('privacyError')],
  ]);

  const getErrorMessage = (input) => {
    const v = input.validity;

    if (v.valueMissing) {
      // Mensajes específicos por campo
      if (input.name === 'privacy') return 'Debes aceptar la política de privacidad para continuar.';
      return 'Este campo es obligatorio.';
    }
    if (v.typeMismatch) {
      if (input.type === 'email') return 'Introduce una dirección de correo electrónico válida.';
      return 'Formato no válido.';
    }
    if (v.tooShort) {
      return `Introduce al menos ${input.minLength} caracteres.`;
    }
    if (v.patternMismatch) {
      if (input.name === 'phone') return 'Introduce un número válido (números, espacios y guiones).';
      return 'Formato no válido.';
    }
    if (v.badInput) return 'Valor no válido.';
    return '';
  };

  const setFieldState = (input, isValid, message = '') => {
    const errorEl = errorMap.get(input.name);
    if (errorEl) errorEl.textContent = message;

    input.classList.toggle('is-invalid', !isValid);
    input.classList.toggle('is-valid', isValid);

    // Para accesibilidad: aria-invalid cuando no es válido
    input.setAttribute('aria-invalid', String(!isValid));
  };

  const validateField = (input) => {
    // teléfono opcional: si está vacío, considéralo válido
    if (input.name === 'phone' && input.value.trim() === '') {
      setFieldState(input, true, '');
      return true;
    }

    const ok = input.checkValidity();
    const msg = ok ? '' : getErrorMessage(input);
    setFieldState(input, ok, msg);
    return ok;
  };

  const focusFirstInvalid = () => {
    const firstInvalid = form.querySelector('.is-invalid');
    if (firstInvalid) firstInvalid.focus({ preventScroll: false });
  };

  // Validación en vivo: al perder el foco (blur) y al introducir datos (input)
  form.addEventListener('blur', (e) => {
    const target = e.target;
    if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
      validateField(target);
    }
  }, true);

  // Debounce simple para no validar en cada pulsación de tecla de forma agresiva
  let t = null;
  form.addEventListener('input', (e) => {
    const target = e.target;
    if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return;

    clearTimeout(t);
    t = setTimeout(() => validateField(target), 200);
  });

  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    statusEl.textContent = '';

    const inputs = Array.from(form.querySelectorAll('input, textarea'));
    const allOk = inputs.every(validateField);

    if (!allOk) {
      statusEl.textContent = 'Revisa los campos resaltados.';
      focusFirstInvalid();
      return;
    }

    // Aquí enviarías los datos al servidor (fetch/AJAX) o dejarías que el formulario actúe
    // Ejemplo placeholder:
    statusEl.textContent = 'Enviando...';

    try {
      // Simulación de envío
      await new Promise((r) => setTimeout(r, 400));
      statusEl.textContent = 'Mensaje enviado correctamente.';
      form.reset();

      // Limpiar estados visuales tras el reset
      inputs.forEach((i) => {
        i.classList.remove('is-valid', 'is-invalid');
        i.removeAttribute('aria-invalid');
        const err = errorMap.get(i.name);
        if (err) err.textContent = '';
      });
    } catch (err) {
      statusEl.textContent = 'Error durante el envío. Inténtalo de nuevo.';
    }
  });
})();

¿Por qué usar la Constraint Validation API?

Muchos reinventan la rueda con expresiones regulares en todas partes y comprobaciones manuales. En realidad, los navegadores modernos ofrecen un sistema robusto ya preparado:

  • checkValidity() verifica la validez del campo sin mostrar los popups del navegador
  • reportValidity() puede mostrar los mensajes nativos (útil en casos sencillos)
  • validity expone el estado detallado (valueMissing, tooShort, typeMismatch, patternMismatch…)

Así te centras en la UX (mensajes, resaltado, accesibilidad), sin duplicar lógicas innecesarias.

Validación «amable»: cuándo mostrar el error

Un error mostrado demasiado pronto puede molestar. Pautas prácticas:

  • muestra los errores al enviar (siempre)
  • muestra los errores al perder el foco (cuando el usuario sale del campo)
  • valida «en vivo» mientras se escribe solo con debounce y solo si el campo ya ha sido tocado (enfoque más avanzado)

En el ejemplo, al enviar validamos todo; durante el rellenado validamos al perder el foco y con un debounce ligero.

Accesibilidad: no es opcional

Dos atenciones marcan la diferencia:

  • enlaza el texto de error al campo con aria-describedby
  • actualiza el estado con aria-invalid cuando sea necesario

Además, usar aria-live="polite" en el texto de error permite que los lectores de pantalla comuniquen el cambio.

FAQ rápidas

¿Debo usar novalidate en el formulario?

Si quieres gestionar tú mismo los mensajes y la UI, sí: novalidate deshabilita los popups nativos del navegador y te permite una UX coherente. Si te basta la validación HTML5 estándar, también puedes no usarlo.

¿Es obligatoria la regex del teléfono?

No. Los números de teléfono son complejos (diferentes formatos). Mejor una validación permisiva del lado del cliente y una normalización del lado del servidor, o librerías dedicadas cuando se necesita alta precisión.

¿Puedo enviar con fetch sin recargar la página?

Sí. En el bloque de envío encuentras ya un punto donde insertar fetch(). Recuerda: validación del servidor siempre, gestión de errores y mensajes claros del lado del cliente.

Conclusión

Un formulario con validación del lado del cliente en JavaScript no tiene por qué ser complicado: HTML5 ya te da reglas sólidas, JavaScript te permite hacerlas más claras, coherentes y accesibles. Si construyes mensajes útiles, resaltas los errores de la manera correcta y no olvidas la accesibilidad, obtienes formularios más rápidos, menos frustración y datos de mejor calidad.

Pubblicato in ,

Se vuoi rimanere aggiornato su Cómo crear un formulario HTML con validación del lado del cliente en JavaScript iscriviti alla nostra newsletter settimanale

Sé el primero en comentar

Deja una respuesta

Tu dirección de correo no será publicada.


*