Módulo 16: Formularios y validación
Objetivos del módulo
- Acceder a un formulario y a sus controles con
document.forms,form.elementsy el atributoname - Leer correctamente el valor de cada tipo de control: texto, número, casillas, botones de opción, listas y fechas
- Gestionar el envío con el evento
submitypreventDefault() - Dominar
FormData: construirlo, recorrerlo, convertirlo en objeto y enviarlo confetch - Aprovechar la validación nativa de HTML5 y la Constraint Validation API
- Mostrar errores personalizados y accesibles (
aria-invalid,aria-describedby) con estilos:user-invalid - Validar en tiempo real, hacer validaciones cruzadas y crear formularios dinámicos
- Guardar borradores automáticamente en
localStorage - Enviar datos con
fetchgestionando los estados de carga, éxito y error - Entender por qué la validación del cliente nunca sustituye a la del servidor
1. Acceder a formularios y controles
Los formularios son la principal forma de que el usuario envíe datos: registrarse, buscar, comprar, comentar… JavaScript te permite leerlos, validarlos y enviarlos sin recargar la página.
Partimos de este formulario (recuerda del curso de HTML que cada control debe tener su label):
<form id="contacto" name="contacto">
<label for="nombre">Nombre</label>
<input id="nombre" name="nombre" required>
<label for="email">Correo</label>
<input id="email" name="email" type="email" required>
<label for="mensaje">Mensaje</label>
<textarea id="mensaje" name="mensaje" rows="4"></textarea>
<button type="submit">Enviar</button>
</form>
Formas de llegar a él:
const formulario = document.querySelector('#contacto'); // con un selector (lo más habitual)
const mismo = document.forms.contacto; // por su atributo name (o id)
const primero = document.forms[0]; // por posición
form.elements y el atributo name
form.elements es una colección con todos los controles del formulario, accesibles por su name (o id):
const { nombre, email, mensaje } = formulario.elements; // desestructuración (módulo 07)
console.log(nombre.value);
console.log(formulario.elements.length); // 4 (los tres campos y el botón)
console.log(email.form === formulario); // true: cada control conoce su formulario
📘 Concepto: El atributo
namees el nombre con el que el dato viaja al servidor y con el que lo encuentras enform.elementsy enFormData. Elidsirve para enlazar la etiqueta (label for) y para CSS. Un campo sinnameno se envía.
💡 Consejo: Acceder por
formulario.elements.nombreen lugar dedocument.querySelector('#nombre')hace el código más robusto: buscas dentro del formulario correcto aunque la página tenga varios formularios con campos que se llamen igual.
2. Leer valores según el tipo de control
La propiedad value siempre devuelve una cadena, pero cada tipo de control tiene sus particularidades.
Texto, textarea y similares
const nombre = formulario.elements.nombre.value.trim(); // trim() quita espacios sobrantes
Números: valueAsNumber
<input type="number" name="cantidad" min="1" max="10" value="3">
<input type="range" name="volumen" min="0" max="100" value="50">
const { cantidad, volumen } = formulario.elements;
console.log(cantidad.value); // "3" ← ¡cadena!
console.log(cantidad.value + 1); // "31" (concatena)
console.log(cantidad.valueAsNumber + 1); // 4 ✅
console.log(volumen.valueAsNumber); // 50
// Si el campo está vacío o el contenido no es un número válido:
console.log(cantidad.valueAsNumber); // NaN → compruébalo con Number.isNaN()
Casillas de verificación (checkbox)
Lo que importa es checked (booleano), no value:
<input type="checkbox" id="boletin" name="boletin" value="si">
<label for="boletin">Quiero recibir el boletín</label>
<fieldset>
<legend>Intereses</legend>
<label><input type="checkbox" name="intereses" value="js" checked> JavaScript</label>
<label><input type="checkbox" name="intereses" value="css"> CSS</label>
<label><input type="checkbox" name="intereses" value="node" checked> Node.js</label>
</fieldset>
console.log(formulario.elements.boletin.checked); // true / false
// Varias casillas con el mismo name: elements.intereses es una colección
const intereses = [...formulario.elements.intereses].filter((c) => c.checked).map((c) => c.value);
console.log(intereses); // ['js', 'node']
// Alternativa con un selector
const marcadas = [...formulario.querySelectorAll('[name="intereses"]:checked')].map((c) => c.value);
Botones de opción (radio)
Con el mismo name, elements.nombre devuelve un RadioNodeList cuyo value es el del botón marcado (o '' si no hay ninguno):
<fieldset>
<legend>Plan</legend>
<label><input type="radio" name="plan" value="basico"> Básico</label>
<label><input type="radio" name="plan" value="pro" checked> Pro</label>
</fieldset>
console.log(formulario.elements.plan.value); // "pro"
formulario.elements.plan.value = 'basico'; // marca el botón "basico"
Listas (select)
<select name="pais">
<option value="">Elige un país</option>
<option value="es" selected>España</option>
<option value="mx">México</option>
</select>
<select name="idiomas" multiple size="3">
<option value="es" selected>Español</option>
<option value="en">Inglés</option>
<option value="fr" selected>Francés</option>
</select>
const { pais, idiomas } = formulario.elements;
console.log(pais.value); // "es"
console.log(pais.selectedIndex); // 1
console.log(pais.selectedOptions[0].textContent); // "España" (el texto visible)
// En un select múltiple, value solo da el PRIMERO: usa selectedOptions
console.log(idiomas.value); // "es"
console.log([...idiomas.selectedOptions].map((o) => o.value)); // ['es', 'fr']
Fechas: valueAsDate y valueAsNumber
<input type="date" name="nacimiento" value="2026-10-04">
const { nacimiento } = formulario.elements;
console.log(nacimiento.value); // "2026-10-04" (siempre formato AAAA-MM-DD)
console.log(nacimiento.valueAsDate); // objeto Date a las 00:00 UTC de ese día
console.log(nacimiento.valueAsDate.toISOString()); // "2026-10-04T00:00:00.000Z"
console.log(nacimiento.valueAsNumber); // 1791072000000 (milisegundos desde 1970, UTC)
nacimiento.valueAsDate = new Date(Date.UTC(2027, 0, 15)); // escribe "2027-01-15"
⚠️ Importante:
valueAsDateinterpreta la fecha en UTC, no en tu zona horaria. En España (UTC+1 o UTC+2),nacimiento.valueAsDate.getDate()puede darte el día correcto, perogetHours()dará 1 o 2, y en zonas con desfase negativo (América)getDate()¡te dará el día anterior! Para fechas de calendario sin hora, trabaja con la cadenavalueo usa los métodos UTC (getUTCDate()). En el módulo 19 verásTemporal.PlainDate, que resuelve esto de raíz.
Ficheros
const { avatar } = formulario.elements; // <input type="file" name="avatar" accept="image/*">
const fichero = avatar.files[0]; // objeto File (o undefined si no se eligió ninguno)
if (fichero) console.log(fichero.name, fichero.size, fichero.type); // "foto.png" 48213 "image/png"
| Control | Propiedad a leer | Tipo |
|---|---|---|
text, email, password, textarea | value | Cadena |
number, range | valueAsNumber | Número (o NaN) |
checkbox | checked | Booleano |
Grupo de radio | elements.nombre.value | Cadena del marcado |
select | value | Cadena |
select multiple | selectedOptions | Colección de option |
date, time, datetime-local | value / valueAsDate / valueAsNumber | Cadena / Date / número |
file | files | FileList |
3. El evento submit
El formulario se envía al pulsar un botón de envío o al pulsar Intro en un campo de texto. Por eso escuchamos submit en el formulario, no click en el botón:
formulario.addEventListener('submit', (evento) => {
evento.preventDefault(); // ⛔ impide el envío tradicional (que recarga la página)
const nombre = formulario.elements.nombre.value.trim();
console.log('Enviando datos de', nombre);
// ...aquí validaríamos y enviaríamos con fetch
});
Detalles útiles:
evento.submitteres el botón que provocó el envío (útil si hay varios: “Guardar” y “Guardar y publicar”).formulario.requestSubmit()envía el formulario desde código como si se pulsara el botón: valida y disparasubmit. En cambio,formulario.submit()envía directamente sin validar ni disparar el evento (casi nunca es lo que quieres).formulario.reset()devuelve todos los campos a su valor inicial.- Los botones dentro de un formulario son
type="submit"por defecto. Si un botón no debe enviar (por ejemplo, “Añadir teléfono”), ponletype="button".
⚠️ Importante: El navegador solo dispara
submitsi la validación nativa pasa (salvo que el formulario tenganovalidate). Si un camporequiredestá vacío, mostrará su burbuja de error y tu manejador ni se ejecutará.
4. FormData
Leer campo a campo es tedioso. FormData recoge de golpe todos los controles con name de un formulario, igual que haría el navegador al enviarlo.
formulario.addEventListener('submit', (e) => {
e.preventDefault();
const datos = new FormData(formulario); // o new FormData(formulario, e.submitter) para incluir el botón
console.log(datos.get('nombre')); // valor de un campo
console.log(datos.getAll('intereses')); // todos los valores de un campo repetido: ['js', 'node']
console.log(datos.has('boletin')); // false si la casilla no está marcada
});
Qué incluye y qué no:
- ✅ Los campos con
name, como cadenas (y losfilecomo objetosFile). - ✅ Las casillas y botones de opción marcados, con su
value(si no tienenvalue, se envía"on"). - ❌ Las casillas sin marcar (no aparecen en absoluto).
- ❌ Los campos deshabilitados (
disabled) y los que no tienenname.
Modificar y recorrer
const datos = new FormData(formulario);
datos.set('origen', 'web'); // crea o sustituye
datos.append('etiquetas', 'nuevo'); // añade (permite valores repetidos)
datos.delete('mensaje');
for (const [clave, valor] of datos) {
console.log(`${clave} = ${valor}`);
}
Convertir en objeto
const objeto = Object.fromEntries(new FormData(formulario));
// { nombre: 'Ana', email: 'ana@correo.es', intereses: 'node', plan: 'pro' }
⚠️ Importante:
Object.fromEntriesse queda solo con el último valor de los campos repetidos (casillas con el mismoname,select multiple). Corrígelo a mano congetAll:objeto.intereses = datos.getAll('intereses');. Y recuerda que todo son cadenas: convierte los números (Number(objeto.edad)) y las casillas (datos.has('boletin')).
Enviar con fetch
Hay dos formas habituales, según lo que espere el servidor:
// 1) Como formulario "multipart" (admite ficheros). fetch pone la cabecera Content-Type sola
await fetch('/api/contacto', { method: 'POST', body: new FormData(formulario) });
// 2) Como JSON (lo más común con APIs REST)
const datos = Object.fromEntries(new FormData(formulario));
await fetch('/api/contacto', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(datos),
});
// Extra: como parámetros de URL (?nombre=Ana&email=...), por ejemplo para un buscador
const consulta = new URLSearchParams(new FormData(formulario)).toString();
💡 Consejo: Con
FormDataen elbodyno pongas tú la cabeceraContent-Type:fetchla genera con el separador (boundary) necesario. Si la escribes a mano, el servidor no podrá leer los datos. Verásfetcha fondo en el módulo 17.
5. Validación nativa de HTML5
Antes de escribir una sola línea de JavaScript, HTML ya valida mucho por ti:
| Atributo | Valida | Ejemplo |
|---|---|---|
required | Que no esté vacío (o que la casilla esté marcada) | <input name="nombre" required> |
type="email", type="url" | Formato de correo o URL | <input type="email"> |
minlength / maxlength | Longitud del texto | <input minlength="8"> |
min / max / step | Rango y saltos en números y fechas | <input type="number" min="1" max="10" step="1"> |
pattern | Expresión regular que debe cumplir todo el valor | <input pattern="[0-9]{5}" title="5 dígitos"> |
<form id="pedido">
<label for="cp">Código postal</label>
<input id="cp" name="cp" required pattern="[0-9]{5}" inputmode="numeric" autocomplete="postal-code">
<label for="unidades">Unidades</label>
<input id="unidades" name="unidades" type="number" min="1" max="99" value="1" required>
<button>Pedir</button>
</form>
Al intentar enviar, el navegador comprueba todo, muestra una burbuja con el mensaje del primer campo erróneo, le da el foco y no dispara submit.
patternusa la sintaxis de expresiones regulares de JavaScript (módulo 08) con el flagv, y se ancla automáticamente:[0-9]{5}equivale a^(?:[0-9]{5})$.inputmode="numeric"muestra el teclado numérico en el móvil yautocompleteayuda al navegador a rellenar datos guardados.- El atributo
novalidateen elformdesactiva las burbujas automáticas, pero la API de validación sigue funcionando: es lo que harás cuando quieras mostrar tus propios mensajes.
⚠️ Importante:
minlengthymaxlengthsolo se comprueban sobre lo que escribe el usuario: si el valor viene del HTML (value="ab") o lo pones desde JavaScript,validity.tooShortseráfalse. Tenlo en cuenta en los tests.
6. La Constraint Validation API
Cada control tiene propiedades y métodos para consultar y controlar su validez desde JavaScript:
| Miembro | Qué hace |
|---|---|
campo.checkValidity() | Devuelve true/false; si es inválido, dispara el evento invalid en el campo |
campo.reportValidity() | Como checkValidity, pero además muestra la burbuja de error |
campo.validity | Objeto ValidityState con el detalle de qué falla |
campo.validationMessage | El mensaje de error (en el idioma del navegador) |
campo.setCustomValidity(msg) | Marca un error propio con ese mensaje; con '' lo quita |
campo.willValidate | false si el campo no se valida (deshabilitado, fieldset, botones type="button"…). Los botones de envío dan true |
form.checkValidity() / form.reportValidity() | Lo mismo para todo el formulario |
ValidityState: qué falla exactamente
| Propiedad | true cuando… |
|---|---|
valueMissing | Falta un valor obligatorio (required) |
typeMismatch | No es un correo o URL válido |
patternMismatch | No cumple pattern |
tooShort / tooLong | No cumple minlength / maxlength |
rangeUnderflow / rangeOverflow | Menor que min / mayor que max |
stepMismatch | No encaja con step |
badInput | El navegador no puede interpretar el valor (letras en un number) |
customError | Hay un error puesto con setCustomValidity |
valid | Ninguno de los anteriores |
const email = formulario.elements.email;
email.value = 'no-es-email';
console.log(email.validity.valid); // false
console.log(email.validity.typeMismatch); // true
console.log(email.validationMessage);
// En un navegador en español: "Incluye una '@' en la dirección de correo electrónico. A 'no-es-email' le falta una '@'."
Mensajes propios según el error
Los mensajes del navegador son correctos pero genéricos. Puedes traducir cada validity a un mensaje tuyo:
function mensajeDeError(campo) {
const v = campo.validity;
if (v.valueMissing) return 'Este campo es obligatorio.';
if (v.typeMismatch) return 'Escribe un correo válido, como nombre@dominio.es.';
if (v.tooShort) return `Escribe al menos ${campo.minLength} caracteres (llevas ${campo.value.length}).`;
if (v.patternMismatch) return campo.dataset.mensajePatron ?? 'El formato no es correcto.';
if (v.rangeUnderflow) return `El mínimo es ${campo.min}.`;
if (v.rangeOverflow) return `El máximo es ${campo.max}.`;
if (v.customError) return campo.validationMessage; // el que pusimos con setCustomValidity
return campo.validationMessage; // cualquier otro caso: el del navegador
}
setCustomValidity: reglas propias
Para reglas que HTML no sabe expresar, marca el error tú mismo. El campo se comporta entonces como cualquier campo inválido: no se envía, aplica :invalid, etc.
const usuario = formulario.elements.usuario;
const reservados = ['admin', 'root', 'soporte'];
usuario.addEventListener('input', () => {
const ocupado = reservados.includes(usuario.value.trim().toLowerCase());
usuario.setCustomValidity(ocupado ? 'Ese nombre de usuario está reservado.' : ''); // '' = válido
});
⚠️ Importante: Un error de
setCustomValidityno se borra solo: permanece hasta que llames asetCustomValidity(''). Por eso se recalcula en cadainput. Si te olvidas, el campo quedará inválido para siempre y el formulario no se enviará.
7. Estilos: :invalid frente a :user-invalid
CSS tiene pseudoclases para el estado de validación, pero :invalid tiene un problema: se aplica desde el primer momento, así que un formulario recién cargado aparece lleno de campos en rojo antes de que el usuario escriba nada.
La solución moderna son :user-invalid y :user-valid, que solo se aplican después de que el usuario haya interactuado con el campo (lo haya modificado y salido de él, o haya intentado enviar el formulario). Funcionan en todos los navegadores actuales.
/* ❌ Rojo desde el principio, aunque el usuario no haya tocado nada */
input:invalid { border-color: crimson; }
/* ✅ Solo después de interactuar */
input:user-invalid,
textarea:user-invalid {
border-color: crimson;
background-color: #fff5f5;
}
input:user-valid {
border-color: seagreen;
}
/* Si gestionas tú los errores con aria-invalid (sección 8) */
[aria-invalid="true"] {
border-color: crimson;
}
💡 Consejo: No comuniques los errores solo con color: alrededor de un 8 % de los hombres tiene algún tipo de daltonismo. Acompaña el borde rojo con un texto de error y, si quieres, un icono.
8. Mensajes de error personalizados y accesibles
Las burbujas nativas no se pueden diseñar y desaparecen enseguida. Lo profesional es mostrar el mensaje junto al campo, de forma que también lo anuncie un lector de pantalla:
<form id="alta" novalidate>
<div class="campo">
<label for="correo">Correo electrónico</label>
<input id="correo" name="correo" type="email" required aria-describedby="correo-ayuda correo-error">
<small id="correo-ayuda">Te enviaremos la confirmación aquí.</small>
<p id="correo-error" class="error" aria-live="polite"></p>
</div>
<button>Darme de alta</button>
</form>
novalidatedesactiva las burbujas: los mensajes los pintamos nosotros.aria-describedbyenlaza el campo con su texto de ayuda y su mensaje de error: al enfocar el campo, el lector de pantalla los lee.aria-invalid="true"indica que el campo tiene un error.aria-live="polite"hace que el lector anuncie el mensaje cuando cambia.
function mostrarError(campo) {
const error = document.querySelector(`#${campo.id}-error`);
const valido = campo.validity.valid;
campo.setAttribute('aria-invalid', String(!valido));
error.textContent = valido ? '' : mensajeDeError(campo); // la función de la sección 6
return valido;
}
const alta = document.querySelector('#alta');
alta.addEventListener('submit', (e) => {
e.preventDefault();
// Ojo: los botones de envío también tienen willValidate === true, así que los excluimos
const campos = [...alta.elements].filter((c) => c.willValidate && c.type !== 'submit');
const resultados = campos.map(mostrarError); // valida TODOS para mostrar todos los errores
if (resultados.includes(false)) {
campos.find((c) => !c.validity.valid).focus(); // lleva el foco al primer error
return;
}
console.log('Todo correcto, enviando...');
});
⚠️ Importante: Usa
mapy noeverypara validar:everyse detiene en el primer campo inválido y el usuario solo vería un error cada vez. Es mucho más cómodo ver todos los problemas a la vez.
9. Validación en tiempo real con input y blur
¿Cuándo validar? Si muestras el error mientras el usuario escribe su correo por primera vez, le estarás regañando por algo que aún no ha terminado. Una estrategia equilibrada, muy usada:
- Al salir del campo (
blur) por primera vez, valida y muestra el error si lo hay. - A partir de ahí, si el campo tiene error, revalida en cada
input, para que el error desaparezca en cuanto se corrija. - Al enviar, valida todo.
for (const campo of alta.elements) {
if (!campo.willValidate || campo.type === 'submit') continue;
campo.addEventListener('blur', () => {
campo.dataset.tocado = 'true';
mostrarError(campo);
});
campo.addEventListener('input', () => {
if (campo.dataset.tocado) mostrarError(campo); // solo si ya lo había visitado
});
}
💡 Consejo: Como
blurno burbujea, aquí registramos un manejador por campo. Con muchos campos o campos dinámicos, usa la delegación confocusouteinputen el formulario (módulo 15):alta.addEventListener('focusout', (e) => { if (e.target.willValidate) ... }).
10. Validaciones cruzadas: confirmar la contraseña
Algunas reglas dependen de varios campos a la vez. El ejemplo clásico es repetir la contraseña:
<label for="clave">Contraseña</label>
<input id="clave" name="clave" type="password" required minlength="8" autocomplete="new-password">
<label for="clave2">Repite la contraseña</label>
<input id="clave2" name="clave2" type="password" required autocomplete="new-password">
<p id="clave2-error" class="error" aria-live="polite"></p>
const { clave, clave2 } = formulario.elements;
function comprobarCoincidencia() {
const coincide = clave2.value === clave.value;
clave2.setCustomValidity(coincide ? '' : 'Las contraseñas no coinciden.');
}
// Hay que revisar al cambiar CUALQUIERA de los dos campos
clave.addEventListener('input', comprobarCoincidencia);
clave2.addEventListener('input', comprobarCoincidencia);
Otros casos típicos: “fecha de fin posterior a la de inicio”, “al menos un teléfono o un correo”, “si eliges ‘empresa’, el CIF es obligatorio”.
💡 Consejo: Pon el error en el campo que el usuario tiene que corregir (aquí, la repetición) y recalcula en los eventos de todos los campos implicados. Si solo escuchas
clave2, al cambiarclaveel error quedaría desactualizado.
11. Formularios dinámicos: añadir y quitar campos
A veces el número de campos no es fijo: varios teléfonos, varias líneas de un pedido, varios participantes… Un template (módulo 14) y la delegación de eventos (módulo 15) lo resuelven con elegancia:
<fieldset id="telefonos">
<legend>Teléfonos</legend>
<div id="lista-telefonos"></div>
<button type="button" id="anadir-telefono">+ Añadir teléfono</button>
</fieldset>
<template id="plantilla-telefono">
<div class="telefono">
<label>Teléfono <span class="numero"></span>
<input type="tel" name="telefonos" required pattern="[0-9 +]{9,15}">
</label>
<button type="button" class="quitar">Quitar</button>
</div>
</template>
const listaTelefonos = document.querySelector('#lista-telefonos');
const plantillaTelefono = document.querySelector('#plantilla-telefono');
const MAXIMO = 3;
function renumerar() {
const filas = listaTelefonos.querySelectorAll('.telefono');
filas.forEach((fila, i) => (fila.querySelector('.numero').textContent = i + 1));
document.querySelector('#anadir-telefono').disabled = filas.length >= MAXIMO;
}
function anadirTelefono() {
const fila = plantillaTelefono.content.firstElementChild.cloneNode(true);
listaTelefonos.append(fila);
renumerar();
fila.querySelector('input').focus(); // accesibilidad: foco al campo nuevo
}
document.querySelector('#anadir-telefono').addEventListener('click', anadirTelefono);
listaTelefonos.addEventListener('click', (e) => {
if (!e.target.closest('.quitar')) return;
e.target.closest('.telefono').remove();
renumerar();
});
anadirTelefono(); // empezamos con uno
Como todos los campos se llaman telefonos, al enviar los recoges con new FormData(formulario).getAll('telefonos'). Y los campos nuevos participan automáticamente en la validación nativa y en form.elements.
12. Autoguardado de borradores en localStorage
Nada frustra más que perder un formulario largo por cerrar la pestaña sin querer. Puedes guardar un borrador mientras el usuario escribe. Usamos localStorage (lo verás a fondo en el módulo 18): guarda cadenas asociadas a una clave, y sobreviven al cerrar el navegador.
const CLAVE_BORRADOR = 'borrador-contacto';
const CAMPOS_PRIVADOS = ['clave', 'clave2']; // ¡nunca guardes contraseñas!
// debounce del módulo 15: guardamos cuando el usuario deja de escribir 500 ms
function debounce(fn, espera) {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), espera);
};
}
const guardarBorrador = debounce(() => {
const datos = Object.fromEntries(new FormData(formulario));
for (const campo of CAMPOS_PRIVADOS) delete datos[campo];
localStorage.setItem(CLAVE_BORRADOR, JSON.stringify(datos));
}, 500);
function restaurarBorrador() {
const guardado = localStorage.getItem(CLAVE_BORRADOR);
if (!guardado) return;
for (const [nombre, valor] of Object.entries(JSON.parse(guardado))) {
const campo = formulario.elements[nombre];
if (campo && 'value' in campo && campo.type !== 'checkbox' && campo.type !== 'file') {
campo.value = valor; // sirve también para grupos de radio (RadioNodeList)
}
}
}
formulario.addEventListener('input', guardarBorrador); // input burbujea: un solo listener
restaurarBorrador();
// Tras enviar con éxito: localStorage.removeItem(CLAVE_BORRADOR);
⚠️ Importante:
localStorageno está cifrado y cualquier script de tu página puede leerlo. No guardes en él contraseñas, datos bancarios ni información sensible. Y comoJSON.parselanza un error si el contenido está corrupto, en producción envuélvelo entry/catch.
13. Enviar con fetch y gestionar los estados
Un envío profesional informa al usuario en todo momento y evita los envíos duplicados:
| Estado | Qué ve el usuario |
|---|---|
| Inactivo | Formulario editable, botón “Enviar” |
| Enviando | Botón deshabilitado con el texto “Enviando…”, formulario con aria-busy="true" |
| Éxito | Mensaje de confirmación, formulario vacío (o redirección) |
| Error | Mensaje de error comprensible; los datos se conservan para reintentar |
Usaremos JSONPlaceholder (https://jsonplaceholder.typicode.com), una API pública y gratuita de pruebas: acepta peticiones POST y responde como si hubiera guardado los datos (con un id nuevo), aunque en realidad no guarda nada.
const boton = formulario.querySelector('[type="submit"]');
const mensaje = document.querySelector('#mensaje-envio'); // un <p role="status">
function ponerEstado(estado, texto = '') {
const enviando = estado === 'enviando';
boton.disabled = enviando;
boton.textContent = enviando ? 'Enviando...' : 'Enviar';
formulario.setAttribute('aria-busy', String(enviando));
mensaje.textContent = texto;
mensaje.className = estado; // para darle estilo: .exito, .error...
}
formulario.addEventListener('submit', async (e) => {
e.preventDefault();
if (!formulario.checkValidity()) return; // (aquí iría la validación de la sección 8)
ponerEstado('enviando');
try {
const respuesta = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(new FormData(formulario))),
signal: AbortSignal.timeout(10_000), // máximo 10 s (módulo 12)
});
if (!respuesta.ok) throw new Error(`El servidor respondió ${respuesta.status}`);
const creado = await respuesta.json();
ponerEstado('exito', `¡Mensaje enviado! Referencia: ${creado.id}`);
formulario.reset();
} catch (error) {
const texto = error.name === 'TimeoutError' ? 'El servidor no responde. Inténtalo de nuevo.' : `No se pudo enviar: ${error.message}`;
ponerEstado('error', texto);
}
});
💡 Consejo: Deshabilitar el botón mientras se envía evita el doble envío (dos pedidos, dos cargos en la tarjeta) cuando el usuario, impaciente, pulsa otra vez. Y fíjate en que, si hay error, no hacemos
reset(): el usuario no debe volver a escribirlo todo.
14. Seguridad: el cliente no es de fiar
⚠️ Importante: La validación en el navegador es una ayuda para el usuario, no una medida de seguridad. Cualquiera puede abrir las DevTools, quitar el
requiredo elpattern, modificar tu JavaScript, o enviar peticiones directamente al servidor concurlo Postman sin pasar por tu formulario. El servidor debe validar siempre todos los datos de nuevo.
| Validación | Objetivo | ¿Imprescindible? |
|---|---|---|
| En el cliente (HTML + JS) | Experiencia de usuario: avisar al instante, sin esperar al servidor | Recomendable |
| En el servidor | Seguridad e integridad de los datos | Siempre |
Otras buenas prácticas:
- Muestra los datos del usuario siempre con
textContent, nunca coninnerHTML(XSS, módulo 14). - Usa
type="password"yautocomplete="new-password"o"current-password"para que los gestores de contraseñas funcionen. - No guardes datos sensibles en
localStorageni los pongas en la URL (GET). - Los mensajes de error del servidor (por ejemplo, “ese correo ya existe”) también deben mostrarse junto al campo correspondiente: puedes usar
setCustomValidityy tu funciónmostrarErrorcon la respuesta del servidor.
15. Ejemplo completo: formulario de registro
Juntamos todo: validación nativa más reglas propias, mensajes accesibles, validación en tiempo real, contraseña repetida, edad mínima calculada a partir de una fecha, al menos un interés, borrador automático y envío con estados.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Registro</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 30rem; margin: 2rem auto; padding: 0 1rem; }
.campo { margin-bottom: 1rem; display: grid; gap: 0.25rem; }
input, select { padding: 0.5rem; font: inherit; }
fieldset { margin-bottom: 1rem; }
.error { color: crimson; margin: 0; min-height: 1.2em; font-size: 0.9rem; }
[aria-invalid="true"] { border: 2px solid crimson; }
#mensaje-envio.exito { color: seagreen; }
#mensaje-envio.error { color: crimson; }
button[disabled] { opacity: 0.6; cursor: progress; }
</style>
<script type="module" src="registro.js"></script>
</head>
<body>
<h1>Crear cuenta</h1>
<form id="registro" novalidate>
<div class="campo">
<label for="nombre">Nombre completo</label>
<input id="nombre" name="nombre" required minlength="3" autocomplete="name" aria-describedby="nombre-error">
<p id="nombre-error" class="error" aria-live="polite"></p>
</div>
<div class="campo">
<label for="email">Correo electrónico</label>
<input id="email" name="email" type="email" required autocomplete="email" aria-describedby="email-error">
<p id="email-error" class="error" aria-live="polite"></p>
</div>
<div class="campo">
<label for="usuario">Nombre de usuario</label>
<input id="usuario" name="usuario" required pattern="[a-z0-9_]{4,15}" autocomplete="username"
data-mensaje-patron="Entre 4 y 15 caracteres: minúsculas, números o guion bajo."
aria-describedby="usuario-error">
<p id="usuario-error" class="error" aria-live="polite"></p>
</div>
<div class="campo">
<label for="clave">Contraseña</label>
<input id="clave" name="clave" type="password" required minlength="8" autocomplete="new-password"
aria-describedby="clave-ayuda clave-error">
<small id="clave-ayuda">Mínimo 8 caracteres, con al menos una letra y un número.</small>
<p id="clave-error" class="error" aria-live="polite"></p>
</div>
<div class="campo">
<label for="clave2">Repite la contraseña</label>
<input id="clave2" name="clave2" type="password" required autocomplete="new-password" aria-describedby="clave2-error">
<p id="clave2-error" class="error" aria-live="polite"></p>
</div>
<div class="campo">
<label for="nacimiento">Fecha de nacimiento</label>
<input id="nacimiento" name="nacimiento" type="date" required aria-describedby="nacimiento-error">
<p id="nacimiento-error" class="error" aria-live="polite"></p>
</div>
<div class="campo">
<label for="pais">País</label>
<select id="pais" name="pais" required aria-describedby="pais-error">
<option value="">Elige un país</option>
<option value="es">España</option>
<option value="mx">México</option>
<option value="ar">Argentina</option>
</select>
<p id="pais-error" class="error" aria-live="polite"></p>
</div>
<fieldset aria-describedby="intereses-error">
<legend>Intereses (elige al menos uno)</legend>
<label><input type="checkbox" name="intereses" value="frontend"> Frontend</label>
<label><input type="checkbox" name="intereses" value="backend"> Backend</label>
<label><input type="checkbox" name="intereses" value="datos"> Datos</label>
<p id="intereses-error" class="error" aria-live="polite"></p>
</fieldset>
<div class="campo">
<label><input id="terminos" name="terminos" type="checkbox" required aria-describedby="terminos-error">
Acepto las condiciones de uso</label>
<p id="terminos-error" class="error" aria-live="polite"></p>
</div>
<button type="submit">Crear cuenta</button>
<p id="mensaje-envio" role="status"></p>
</form>
</body>
</html>
// registro.js
const formulario = document.querySelector('#registro');
const { clave, clave2, nacimiento, usuario } = formulario.elements;
const casillasIntereses = [...formulario.elements.intereses];
const boton = formulario.querySelector('[type="submit"]');
const mensajeEnvio = document.querySelector('#mensaje-envio');
const EDAD_MINIMA = 14;
const CLAVE_BORRADOR = 'borrador-registro';
const NO_GUARDAR = ['clave', 'clave2', 'terminos'];
// ---------- Reglas propias (setCustomValidity) ----------
function calcularEdad(fechaTexto) {
const [anio, mes, dia] = fechaTexto.split('-').map(Number); // evitamos el problema de UTC
const hoy = new Date();
let edad = hoy.getFullYear() - anio;
if (hoy.getMonth() + 1 < mes || (hoy.getMonth() + 1 === mes && hoy.getDate() < dia)) edad--;
return edad;
}
const reglas = {
clave: () => (/[a-z]/i.test(clave.value) && /\d/.test(clave.value) ? '' : 'Incluye al menos una letra y un número.'),
clave2: () => (clave2.value === clave.value ? '' : 'Las contraseñas no coinciden.'),
nacimiento: () => {
if (!nacimiento.value) return ''; // de "vacío" ya se encarga required
const edad = calcularEdad(nacimiento.value);
if (edad < 0) return 'La fecha no puede estar en el futuro.';
return edad < EDAD_MINIMA ? `Debes tener al menos ${EDAD_MINIMA} años.` : '';
},
usuario: () => (['admin', 'root'].includes(usuario.value) ? 'Ese nombre de usuario está reservado.' : ''),
intereses: () => (casillasIntereses.some((c) => c.checked) ? '' : 'Elige al menos un interés.'),
};
function aplicarReglas() {
for (const [nombre, regla] of Object.entries(reglas)) {
const mensaje = regla();
if (nombre === 'intereses') casillasIntereses[0].setCustomValidity(mensaje); // el error va en la primera casilla
else formulario.elements[nombre].setCustomValidity(mensaje);
}
}
// ---------- Mensajes ----------
function mensajeDeError(campo) {
const v = campo.validity;
if (v.valueMissing) return campo.type === 'checkbox' ? 'Debes aceptar las condiciones.' : 'Este campo es obligatorio.';
if (v.typeMismatch) return 'Escribe un correo válido, como nombre@dominio.es.';
if (v.tooShort) return `Escribe al menos ${campo.minLength} caracteres.`;
if (v.patternMismatch) return campo.dataset.mensajePatron ?? 'El formato no es correcto.';
return campo.validationMessage;
}
function idError(campo) {
return campo.name === 'intereses' ? 'intereses-error' : `${campo.id}-error`;
}
function mostrarError(campo) {
const valido = campo.validity.valid;
campo.setAttribute('aria-invalid', String(!valido));
document.getElementById(idError(campo)).textContent = valido ? '' : mensajeDeError(campo);
return valido;
}
// Campos que validamos: sin el botón de envío, y las casillas de intereses representadas por la primera
const esCampo = (c) => c.willValidate && c.type !== 'submit';
const camposValidables = () =>
[...formulario.elements].filter((c) => esCampo(c) && (c.name !== 'intereses' || c === casillasIntereses[0]));
// ---------- Validación en tiempo real (delegación) ----------
formulario.addEventListener('focusout', (e) => {
if (!esCampo(e.target) || e.target.name === 'intereses') return;
e.target.dataset.tocado = 'true';
aplicarReglas();
mostrarError(e.target);
});
formulario.addEventListener('input', (e) => {
aplicarReglas();
if (e.target.name === 'intereses') mostrarError(casillasIntereses[0]);
else if (e.target.dataset.tocado) mostrarError(e.target);
// Validación cruzada: si cambia la clave, revisamos también la repetición ya visitada
if (e.target === clave && clave2.dataset.tocado) mostrarError(clave2);
guardarBorrador();
});
// ---------- Borrador en localStorage ----------
let temporizadorBorrador;
function guardarBorrador() {
clearTimeout(temporizadorBorrador);
temporizadorBorrador = setTimeout(() => {
const datos = new FormData(formulario);
const borrador = Object.fromEntries(datos);
borrador.intereses = datos.getAll('intereses');
for (const nombre of NO_GUARDAR) delete borrador[nombre];
localStorage.setItem(CLAVE_BORRADOR, JSON.stringify(borrador));
}, 500);
}
function restaurarBorrador() {
try {
const borrador = JSON.parse(localStorage.getItem(CLAVE_BORRADOR) ?? '{}');
for (const [nombre, valor] of Object.entries(borrador)) {
if (nombre === 'intereses') casillasIntereses.forEach((c) => (c.checked = valor.includes(c.value)));
else if (formulario.elements[nombre]) formulario.elements[nombre].value = valor;
}
} catch {
localStorage.removeItem(CLAVE_BORRADOR); // borrador corrupto: lo descartamos
}
}
// ---------- Envío ----------
function ponerEstado(estado, texto = '') {
const enviando = estado === 'enviando';
boton.disabled = enviando;
boton.textContent = enviando ? 'Creando cuenta...' : 'Crear cuenta';
formulario.setAttribute('aria-busy', String(enviando));
mensajeEnvio.className = estado;
mensajeEnvio.textContent = texto;
}
formulario.addEventListener('submit', async (e) => {
e.preventDefault();
aplicarReglas();
const campos = camposValidables();
const resultados = campos.map(mostrarError);
if (resultados.includes(false)) {
campos.find((c) => !c.validity.valid).focus();
return;
}
const datos = new FormData(formulario);
const cuerpo = {
nombre: datos.get('nombre').trim(),
email: datos.get('email').trim().toLowerCase(),
usuario: datos.get('usuario'),
nacimiento: datos.get('nacimiento'),
pais: datos.get('pais'),
intereses: datos.getAll('intereses'),
// La contraseña viajaría por HTTPS; el servidor la guardaría cifrada (hash), nunca tal cual
clave: datos.get('clave'),
};
ponerEstado('enviando');
try {
const respuesta = await fetch('https://jsonplaceholder.typicode.com/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cuerpo),
signal: AbortSignal.timeout(10_000),
});
if (!respuesta.ok) throw new Error(`Error ${respuesta.status}`);
const creado = await respuesta.json();
ponerEstado('exito', `¡Bienvenido, ${creado.nombre}! Tu cuenta tiene el id ${creado.id}.`);
formulario.reset();
clearTimeout(temporizadorBorrador); // que un guardado pendiente no resucite el borrador
localStorage.removeItem(CLAVE_BORRADOR);
formulario.querySelectorAll('[data-tocado]').forEach((c) => delete c.dataset.tocado);
} catch (error) {
ponerEstado('error', `No se pudo crear la cuenta (${error.message}). Revisa tu conexión e inténtalo de nuevo.`);
}
});
restaurarBorrador();
Cómo funciona cada pieza:
| Pieza | Conceptos |
|---|---|
novalidate + mostrarError | Mensajes propios junto al campo, aria-invalid, aria-describedby, aria-live |
reglas + aplicarReglas | setCustomValidity para reglas que HTML no expresa, incluida la validación cruzada y “al menos uno” |
calcularEdad | Fecha leída como cadena para evitar el desfase de valueAsDate en UTC |
focusout / input en el formulario | Validación en tiempo real con delegación |
guardarBorrador / restaurarBorrador | FormData, getAll, localStorage, debounce, sin guardar contraseñas |
submit | preventDefault, validación completa, foco al primer error, fetch con estados y tiempo máximo |
✏️ Ejercicio: Añade un indicador de fortaleza de la contraseña (débil, media, fuerte) que se actualice con cada
input, basado en la longitud y en si contiene minúsculas, mayúsculas, números y símbolos. Muéstralo con una barra que use una variable CSS--fuerzay anúncialo conaria-live.
16. Ejercicios
Ejercicio 1: Calculadora de presupuesto
Crea un formulario con un select de producto (cada opción con un data-precio), un input type="number" de unidades (1-20), casillas de extras (“Envío urgente +5 €”, “Envoltorio +2 €”) y un grupo de radio con el método de pago. Muestra el total en tiempo real (evento input en el formulario) usando valueAsNumber, checked y selectedOptions.
Ejercicio 2: Inspector de FormData
Haz un formulario con al menos un campo de cada tipo (texto, número, fecha, casillas repetidas, radio, select multiple, un campo deshabilitado y uno sin name). Al enviarlo, muestra en una tabla todas las entradas de FormData y, debajo, el resultado de Object.fromEntries. Explica qué campos faltan y por qué, y corrige los valores múltiples con getAll.
Ejercicio 3: Mensajes personalizados
Crea un formulario con novalidate con campos de correo, código postal (pattern), edad (min/max) y comentario (maxlength). Escribe una función que traduzca cada propiedad de validity a un mensaje en español y muéstralo debajo del campo, con aria-invalid y aria-describedby.
Ejercicio 4: Rango de fechas
Crea un formulario de reserva con fecha de entrada y de salida. Valida que la entrada no sea anterior a hoy y que la salida sea posterior a la entrada (validación cruzada con setCustomValidity). Actualiza el atributo min de la fecha de salida cada vez que cambie la de entrada y muestra el número de noches.
Ejercicio 5: Lista de participantes
Haz un formulario de inscripción a un evento donde se puedan añadir hasta cinco participantes (nombre y correo) con un template, y quitar cualquiera salvo el primero. Al enviar, construye un array de objetos { nombre, correo } a partir de getAll('nombre') y getAll('correo').
Ejercicio 6: Borrador con aviso
Añade a un formulario de contacto el guardado automático de borradores. Al cargar la página, si hay un borrador, muestra un aviso “Hemos recuperado tu borrador” con un botón “Descartar” que vacíe el formulario y borre el borrador. Muestra también “Guardado ✓” unos segundos tras cada guardado.
Ejercicio 7: Envío con reintento
Envía un formulario a https://jsonplaceholder.typicode.com/posts mostrando los estados de carga, éxito y error. Simula un error cambiando la URL a una inexistente (/postsx, que responde 404) y añade un botón “Reintentar” en el mensaje de error que reenvíe los mismos datos sin que el usuario tenga que tocar nada.
Resumen
| Concepto | Sintaxis / clave |
|---|---|
| Acceder | document.forms.nombre, form.elements.campo, campo.form |
| Texto | campo.value (cadena; usa trim()) |
| Números | campo.valueAsNumber (NaN si está vacío) |
| Casillas / radio | casilla.checked; form.elements.grupo.value |
select multiple | [...select.selectedOptions].map((o) => o.value) |
| Fechas | value ('AAAA-MM-DD'), valueAsDate (en UTC) |
| Envío | form.addEventListener('submit', (e) => { e.preventDefault(); ... }) |
| Enviar desde código | form.requestSubmit() (valida); form.reset() |
| FormData | new FormData(form), get, getAll, set, append, has, for...of |
| A objeto | Object.fromEntries(datos) + getAll para campos repetidos |
| Validación HTML | required, type, minlength, maxlength, min, max, step, pattern, novalidate |
| Constraint Validation API | checkValidity(), reportValidity(), validity.*, validationMessage |
| Reglas propias | campo.setCustomValidity('mensaje') y '' para quitarlo |
| Estilos | :user-invalid / :user-valid (tras interactuar) frente a :invalid |
| Accesibilidad | aria-invalid, aria-describedby, aria-live, foco al primer error |
| Tiempo real | blur/focusout la primera vez, input después |
| Dinámicos | template + cloneNode + delegación; getAll al enviar |
| Borradores | localStorage.setItem(clave, JSON.stringify(datos)) con debounce |
| Estados de envío | Botón disabled, aria-busy, mensajes de éxito y error |
| Seguridad | El servidor siempre vuelve a validar |