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 navegadorreportValidity()puede mostrar los mensajes nativos (útil en casos sencillos)validityexpone 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-invalidcuando 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 HTML & CSS, JavaScript
Sé el primero en comentar