Módulo 16: Formularios y validación

Objetivos del módulo

  • Acceder a un formulario y a sus controles con document.forms, form.elements y el atributo name
  • 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 submit y preventDefault()
  • Dominar FormData: construirlo, recorrerlo, convertirlo en objeto y enviarlo con fetch
  • 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 fetch gestionando 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 name es el nombre con el que el dato viaja al servidor y con el que lo encuentras en form.elements y en FormData. El id sirve para enlazar la etiqueta (label for) y para CSS. Un campo sin name no se envía.

💡 Consejo: Acceder por formulario.elements.nombre en lugar de document.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: valueAsDate interpreta 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, pero getHours() 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 cadena value o usa los métodos UTC (getUTCDate()). En el módulo 19 verás Temporal.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.submitter es 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 dispara submit. 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”), ponle type="button".

⚠️ Importante: El navegador solo dispara submit si la validación nativa pasa (salvo que el formulario tenga novalidate). Si un campo required está 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 los file como objetos File).
  • ✅ Las casillas y botones de opción marcados, con su value (si no tienen value, se envía "on").
  • ❌ Las casillas sin marcar (no aparecen en absoluto).
  • ❌ Los campos deshabilitados (disabled) y los que no tienen name.

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.fromEntries se queda solo con el último valor de los campos repetidos (casillas con el mismo name, select multiple). Corrígelo a mano con getAll: 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 FormData en el body no pongas tú la cabecera Content-Type: fetch la genera con el separador (boundary) necesario. Si la escribes a mano, el servidor no podrá leer los datos. Verás fetch a 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.

  • pattern usa la sintaxis de expresiones regulares de JavaScript (módulo 08) con el flag v, y se ancla automáticamente: [0-9]{5} equivale a ^(?:[0-9]{5})$.
  • inputmode="numeric" muestra el teclado numérico en el móvil y autocomplete ayuda al navegador a rellenar datos guardados.
  • El atributo novalidate en el form desactiva las burbujas automáticas, pero la API de validación sigue funcionando: es lo que harás cuando quieras mostrar tus propios mensajes.

⚠️ Importante: minlength y maxlength solo se comprueban sobre lo que escribe el usuario: si el valor viene del HTML (value="ab") o lo pones desde JavaScript, validity.tooShort será 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 setCustomValidity no se borra solo: permanece hasta que llames a setCustomValidity(''). Por eso se recalcula en cada input. 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>
  • novalidate desactiva las burbujas: los mensajes los pintamos nosotros.
  • aria-describedby enlaza 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 map y no every para validar: every se 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:

  1. Al salir del campo (blur) por primera vez, valida y muestra el error si lo hay.
  2. A partir de ahí, si el campo tiene error, revalida en cada input, para que el error desaparezca en cuanto se corrija.
  3. 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 blur no burbujea, aquí registramos un manejador por campo. Con muchos campos o campos dinámicos, usa la delegación con focusout e input en 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 cambiar clave el 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: localStorage no está cifrado y cualquier script de tu página puede leerlo. No guardes en él contraseñas, datos bancarios ni información sensible. Y como JSON.parse lanza un error si el contenido está corrupto, en producción envuélvelo en try/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 required o el pattern, modificar tu JavaScript, o enviar peticiones directamente al servidor con curl o 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 con innerHTML (XSS, módulo 14).
  • Usa type="password" y autocomplete="new-password" o "current-password" para que los gestores de contraseñas funcionen.
  • No guardes datos sensibles en localStorage ni 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 setCustomValidity y tu función mostrarError con 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 --fuerza y anúncialo con aria-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