Módulo 08: Formularios

Objetivos del módulo

  • Entender qué hace el navegador con un formulario y qué cambia cuando lo gestiona React
  • Crear campos controlados y seguir el ciclo completo de una pulsación de tecla
  • Manejar todos los tipos de campo: texto, número, fecha, select, checkbox, radio, textarea y fichero
  • Usar campos no controlados con defaultValue, referencias y FormData
  • Elegir entre controlado y no controlado con criterio
  • Validar con los atributos de HTML y con funciones propias, y decidir cuándo mostrar cada error
  • Hacer formularios accesibles con label, fieldset, aria-invalid y aria-describedby
  • Generar identificadores únicos con useId
  • Enviar datos a la API EMPRESA y mostrar los errores de validación del servidor
  • Enviar con <form action> y formAction, las Actions de React 19
  • Construir un formulario multipaso que no pierda datos
  • Diagnosticar los avisos de consola y los errores más frecuentes

💡 Consejo: Casi todo este módulo funciona igual en React 18.3 y en React 19.3. Lo que no, lleva su marca 🆕 o 🔄 en el encabezado: <form action> (19.0), el formato de useId (19.1 y 19.2), submitter, onReset tras una Action y defaultValue en campos numéricos (19.3). Hemos ejecutado la misma prueba en 18.3.1, 19.0.9, 19.1.10, 19.2.9 y 19.3.1 para comprobar cada marca.


1. Formularios en HTML y en React

Un formulario es la forma en que una aplicación de gestión recibe datos: el alta de un empleado, un pedido, un inicio de sesión. Antes de escribir React conviene recordar qué hace el navegador sin ayuda de nadie. Si necesitas repasarlo, tienes los formularios en el Curso de HTML y CSS.

Cuando pulsas el botón de un <form> o pulsas Intro dentro de un campo, el navegador hace cuatro cosas: comprueba las reglas de validación de HTML, lanza el evento submit, recoge los campos que tienen atributo name y navega a la URL de action con esos datos. Esa navegación recarga la página entera.

En una aplicación de una sola página no queremos eso. Recargar borraría todo el estado de React, y además queremos enviar los datos con fetch, no navegar. Por eso la primera línea de casi todo manejador de envío es e.preventDefault(), que ya conoces del módulo 05.

Prepara el proyecto

Para probar los ejemplos crea un proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:

npm create vite@latest modulo-08 -- --template react-ts
cd modulo-08
npm install
npm run dev

Cada ejemplo completo sustituye a src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas, que usan todos los ejemplos:

:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  max-width: 620px;
  margin: 0 0 1rem;
}

.tarjeta h2 {
  margin: 0 0 0.75rem;
  font-size: 1.15rem;
  color: #1a237e;
}

.tarjeta h3 {
  margin: 0 0 0.75rem;
  font-size: 1rem;
  color: #283593;
}

.campo {
  display: grid;
  gap: 0.25rem;
  margin-bottom: 0.75rem;
}

.campo label,
.campo > span,
legend {
  font-weight: 600;
  font-size: 0.9rem;
}

input:not([type='checkbox'], [type='radio'], [type='range'], [type='color']),
select,
textarea {
  font: inherit;
  padding: 0.4rem 0.5rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
}

[aria-invalid='true'] {
  border-color: #c62828;
  outline-color: #c62828;
  background: #fff5f5;
}

.error {
  color: #c62828;
  font-size: 0.85rem;
  margin: 0;
}

.ayuda {
  color: #546e7a;
  font-size: 0.85rem;
  margin: 0;
}

.exito {
  color: #2e7d32;
  font-weight: 600;
}

fieldset {
  border: 1px solid #c5cae9;
  border-radius: 6px;
  margin: 0 0 0.75rem;
}

.opcion {
  display: inline-flex;
  gap: 0.3rem;
  margin-right: 1rem;
}

.fila {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  flex-wrap: wrap;
}

button {
  font: inherit;
  padding: 0.4rem 0.9rem;
  border: 1px solid #3f51b5;
  border-radius: 6px;
  background: #3f51b5;
  color: #fff;
  cursor: pointer;
}

button[type='button'],
button[type='reset'] {
  background: #fff;
  color: #283593;
}

button:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

pre {
  background: #eceff1;
  border-radius: 6px;
  padding: 0.6rem;
  font-size: 0.8rem;
  overflow: auto;
}

.pasos {
  display: flex;
  gap: 0.5rem;
  list-style: none;
  padding: 0;
  margin: 0 0 1rem;
}

.pasos li {
  flex: 1;
  padding: 0.3rem 0.5rem;
  border-radius: 6px;
  background: #eceff1;
  font-size: 0.85rem;
  text-align: center;
}

.pasos li[aria-current='step'] {
  background: #3f51b5;
  color: #fff;
  font-weight: 600;
}

Los ejemplos de envío usan la API EMPRESA. Descarga recursos/empresa-api.zip, descomprímelo y arráncalo con node server.mjs. Queda en http://localhost:3000/api, y sus datos vuelven al estado inicial cada vez que lo reinicias.

El primer formulario

import { useState, type SubmitEvent } from 'react';

export default function AltaRapida() {
  const [nombre, setNombre] = useState('');
  const [mensaje, setMensaje] = useState('');

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault(); // sin esto, el navegador recarga la página
    setMensaje(`Departamento ${nombre} listo para guardar`);
    setNombre('');
  }

  return (
    <form className="tarjeta" onSubmit={enviar}>
      <h2>Nuevo departamento</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" value={nombre} onChange={(e) => setNombre(e.target.value)} />
      </div>
      <button>Guardar</button>
      {mensaje && <p className="exito">{mensaje}</p>}
    </form>
  );
}

Hay tres detalles que se repiten en todo el módulo.

  • onSubmit va en el <form>, no en el onClick del botón. Si escuchas el clic, pulsar Intro dentro del campo no hace lo que el usuario espera. El evento submit cubre las dos formas de enviar, y además solo salta si la validación de HTML ha pasado.
  • htmlFor conecta la etiqueta con el campo. En JSX for es una palabra reservada de JavaScript, igual que class. El módulo 02 explica por qué.
  • <button> dentro de un formulario es de tipo submit si no dices otra cosa. Un botón “Cancelar” sin type="button" envía el formulario. Es uno de los errores más repetidos.

💡 Consejo: SubmitEvent es el tipo que importas de react, no el SubmitEvent global del navegador. En @types/react 19.3.0, la versión de este curso, el antiguo FormEvent aparece marcado como obsoleto, y su comentario te dirige a ChangeEvent, InputEvent o SubmitEvent. Si tu versión de @types/react todavía no exporta SubmitEvent, escribe React.FormEvent<HTMLFormElement>: el código funciona igual, porque esto solo afecta a los tipos.


2. Campos controlados

En HTML, cada <input> guarda su propio valor. En React queremos que los datos vivan en el estado, porque así la interfaz se calcula a partir de ellos. Un campo controlado es un campo cuyo valor lo dicta el estado de React.

Hacen falta dos piezas: value para decirle al campo qué mostrar, y onChange para enterarte de lo que el usuario intenta escribir.

<input value={nombre} onChange={(e) => setNombre(e.target.value)} />

Ciclo en seis pasos: tecla, onChange, setNombre, render, commit en el DOM y pantalla

Figura 1. Cada pulsación da una vuelta completa por el estado antes de quedar en pantalla.

📘 Concepto: En un campo controlado la fuente de verdad es el estado de React. El navegador propone un valor nuevo, tu código decide qué hacer con él y React escribe en el campo el resultado. Si no actualizas el estado, React devuelve el campo a su valor anterior.

Por qué onChange no es el change del DOM

En JavaScript sin React, el evento change de un campo de texto salta al salir del campo. El onChange de React salta con cada cambio, es decir, lo que en el DOM sería el evento input. No es un descuido: el ciclo controlado necesita enterarse de cada tecla. Por eso casi nunca verás onInput en React.

Transformar lo que se escribe

Como tu código se interpone entre la tecla y la pantalla, puedes filtrar el valor. Este NIF se escribe siempre en mayúsculas, y rechaza cualquier carácter que no sea letra o cifra:

import { useState } from 'react';

const SOLO_NIF = /[^0-9A-Z]/g;

export default function CampoNif() {
  const [nif, setNif] = useState('');

  return (
    <form className="tarjeta">
      <div className="campo">
        <label htmlFor="nif">NIF del cliente</label>
        <input
          id="nif"
          value={nif}
          maxLength={9}
          onChange={(e) => setNif(e.target.value.toUpperCase().replace(SOLO_NIF, ''))}
        />
      </div>
      <p className="ayuda">Llevas {nif.length} de 9 caracteres</p>
    </form>
  );
}

Si tecleas 12a-b, el campo muestra 12AB. El guion nunca llega a verse. El contador es estado derivado, como en el módulo 04: se calcula en cada render y no necesita su propio useState.

⚠️ Importante: Transformar el valor tiene un precio: el cursor salta al final. Cuando el valor del estado no coincide con lo que hay en el DOM, React tiene que reescribirlo, y al reescribir value el navegador coloca el cursor al final. Lo hemos probado en Chromium: con 12AB escrito, colocamos el cursor tras el 1 y tecleamos z. El campo muestra 1Z2AB, pero el cursor queda en la posición 5, y la siguiente tecla, y, produce 1Z2ABY y no 1ZY2AB. Con una cifra, que no se transforma, el cursor se queda donde estaba. Si el campo es largo y se edita por el medio, muestra las mayúsculas con CSS (text-transform: uppercase) y convierte el dato al perder el foco o al enviar.

Un objeto para todo el formulario

Un useState por campo funciona con tres campos, pero se vuelve pesado con diez. Lo habitual es un objeto con una propiedad por campo, y una sola función que usa el atributo name para saber qué propiedad cambiar:

import { useState, type ChangeEvent } from 'react';

const FICHA_VACIA = { nombre: '', apellidos: '', oficio: '' };

export default function FichaEmpleado() {
  const [ficha, setFicha] = useState(FICHA_VACIA);

  function cambiar(e: ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    setFicha((previa) => ({ ...previa, [name]: value }));
  }

  return (
    <form className="tarjeta">
      <h2>Ficha de empleado</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" name="nombre" value={ficha.nombre} onChange={cambiar} />
      </div>
      <div className="campo">
        <label htmlFor="apellidos">Apellidos</label>
        <input id="apellidos" name="apellidos" value={ficha.apellidos} onChange={cambiar} />
      </div>
      <div className="campo">
        <label htmlFor="oficio">Oficio</label>
        <input id="oficio" name="oficio" value={ficha.oficio} onChange={cambiar} />
      </div>
      <pre>{JSON.stringify(ficha, null, 2)}</pre>
    </form>
  );
}

Fíjate en dos cosas. [name]: value es una propiedad calculada de JavaScript: la clave del objeto sale del valor de la variable. Y cada name debe coincidir exactamente con una propiedad del estado, porque si escribes mal uno no habrá error de compilación: el objeto ganará una propiedad nueva, y el campo no cambiará. TypeScript no puede comprobarlo porque e.target.name es un string cualquiera.

La copia con ...previa es imprescindible. Sin ella perderías los demás campos: el módulo 04 explica por qué el estado no se muta.

💡 Consejo: Extrae VACIO o FICHA_VACIA fuera del componente. Te servirá para el estado inicial y para limpiar el formulario tras enviarlo, sin escribir dos veces la misma lista de campos.


3. Todos los tipos de campo

Cada tipo de campo tiene sus particularidades. La tabla resume cómo se controla cada uno, y debajo está el ejemplo completo.

Campo Prop controlada Cómo se lee en onChange Tipo del dato Cuidado con
text, email, password, tel, url value e.target.value string type no valida hasta enviar
number value e.target.value o valueAsNumber string o number Vacío da '' o NaN
range value e.target.valueAsNumber number Siempre tiene un valor
date, time, month value e.target.value string Formato AAAA-MM-DD
color value e.target.value string Formato #rrggbb
textarea value e.target.value string No uses hijos
select value en el <select> e.target.value string Convierte si esperas número
select multiple value con un array e.target.selectedOptions string[] No basta con value
checkbox checked e.target.checked boolean No leas value
grupo de checkbox checked calculado Tu función añade o quita string[] Actualiza el array sin mutarlo
radio checked calculado Fijas la opción elegida string Comparten name
file Ninguna e.target.files File Solo no controlado
import { useState, type ChangeEvent } from 'react';

const DEPARTAMENTOS = [
  { deptId: 10, nombre: 'DIRECCIÓN' },
  { deptId: 20, nombre: 'CONTABILIDAD' },
  { deptId: 30, nombre: 'INFORMÁTICA' },
];
const IDIOMAS = ['Español', 'Inglés', 'Francés', 'Alemán'];
const TURNOS = ['Mañana', 'Tarde', 'Noche'];
const TECNOLOGIAS = ['React', 'Angular', 'Java', 'SQL'];

interface Datos {
  nombre: string;
  email: string;
  clave: string;
  salario: string;
  jornada: number;
  fechaAlta: string;
  color: string;
  notas: string;
  deptId: number;
  idiomas: string[];
  teletrabajo: boolean;
  turno: string;
  tecnologias: string[];
}

const INICIAL: Datos = {
  nombre: 'Marta',
  email: '',
  clave: '',
  salario: '2100',
  jornada: 40,
  fechaAlta: '2026-11-02',
  color: '#3f51b5',
  notas: '',
  deptId: 30,
  idiomas: ['Español'],
  teletrabajo: true,
  turno: 'Mañana',
  tecnologias: ['React'],
};

export default function TodosLosCampos() {
  const [datos, setDatos] = useState(INICIAL);
  const [cv, setCv] = useState<File | null>(null);

  // Una sola función para actualizar cualquier propiedad, comprobada por TypeScript
  function poner<K extends keyof Datos>(campo: K, valor: Datos[K]) {
    setDatos((previos) => ({ ...previos, [campo]: valor }));
  }

  function alternarTecnologia(tecnologia: string) {
    const marcada = datos.tecnologias.includes(tecnologia);
    poner(
      'tecnologias',
      marcada
        ? datos.tecnologias.filter((t) => t !== tecnologia)
        : [...datos.tecnologias, tecnologia],
    );
  }

  function elegirCv(e: ChangeEvent<HTMLInputElement>) {
    setCv(e.target.files?.[0] ?? null);
  }

  return (
    <form className="tarjeta">
      <h2>Todos los tipos de campo</h2>

      <div className="campo">
        <label htmlFor="nombre">Nombre (text)</label>
        <input id="nombre" value={datos.nombre} onChange={(e) => poner('nombre', e.target.value)} />
      </div>

      <div className="campo">
        <label htmlFor="email">Email (email)</label>
        <input id="email" type="email" value={datos.email} onChange={(e) => poner('email', e.target.value)} />
      </div>

      <div className="campo">
        <label htmlFor="clave">Contraseña (password)</label>
        <input id="clave" type="password" value={datos.clave} onChange={(e) => poner('clave', e.target.value)} />
      </div>

      <div className="campo">
        <label htmlFor="salario">Salario (number)</label>
        <input id="salario" type="number" min="0" step="50" value={datos.salario} onChange={(e) => poner('salario', e.target.value)} />
      </div>

      <div className="campo">
        <label htmlFor="jornada">Jornada: {datos.jornada} h (range)</label>
        <input id="jornada" type="range" min="10" max="40" step="5" value={datos.jornada} onChange={(e) => poner('jornada', e.target.valueAsNumber)} />
      </div>

      <div className="campo">
        <label htmlFor="fecha">Fecha de alta (date)</label>
        <input id="fecha" type="date" value={datos.fechaAlta} onChange={(e) => poner('fechaAlta', e.target.value)} />
      </div>

      <div className="campo">
        <label htmlFor="color">Color de la ficha (color)</label>
        <input id="color" type="color" value={datos.color} onChange={(e) => poner('color', e.target.value)} />
      </div>

      <div className="campo">
        <label htmlFor="notas">Notas (textarea)</label>
        <textarea id="notas" rows={2} value={datos.notas} onChange={(e) => poner('notas', e.target.value)} />
      </div>

      <div className="campo">
        <label htmlFor="dept">Departamento (select)</label>
        <select id="dept" value={datos.deptId} onChange={(e) => poner('deptId', Number(e.target.value))}>
          {DEPARTAMENTOS.map((d) => (
            <option key={d.deptId} value={d.deptId}>{d.nombre}</option>
          ))}
        </select>
      </div>

      <div className="campo">
        <label htmlFor="idiomas">Idiomas (select multiple)</label>
        <select
          id="idiomas"
          multiple
          size={4}
          value={datos.idiomas}
          onChange={(e) => poner('idiomas', Array.from(e.target.selectedOptions, (o) => o.value))}
        >
          {IDIOMAS.map((i) => (
            <option key={i} value={i}>{i}</option>
          ))}
        </select>
      </div>

      <div className="campo">
        <label className="opcion">
          <input type="checkbox" checked={datos.teletrabajo} onChange={(e) => poner('teletrabajo', e.target.checked)} />
          Teletrabajo (checkbox)
        </label>
      </div>

      <fieldset>
        <legend>Turno (radio)</legend>
        {TURNOS.map((t) => (
          <label key={t} className="opcion">
            <input type="radio" name="turno" value={t} checked={datos.turno === t} onChange={() => poner('turno', t)} />
            {t}
          </label>
        ))}
      </fieldset>

      <fieldset>
        <legend>Tecnologías (grupo de checkbox)</legend>
        {TECNOLOGIAS.map((t) => (
          <label key={t} className="opcion">
            <input type="checkbox" checked={datos.tecnologias.includes(t)} onChange={() => alternarTecnologia(t)} />
            {t}
          </label>
        ))}
      </fieldset>

      <div className="campo">
        <label htmlFor="cv">Currículum (file, siempre no controlado)</label>
        <input id="cv" type="file" accept=".pdf" onChange={elegirCv} />
        {cv && <p className="ayuda">{cv.name}: {cv.size} bytes</p>}
      </div>

      <pre>{JSON.stringify(datos, null, 2)}</pre>
    </form>
  );
}

Formulario con todos los tipos de campo rellenos y, debajo, el objeto de estado en JSON

Figura 2. Cada control refleja su propiedad del estado, que se muestra debajo en JSON.

La función genérica poner merece una mirada. Con K extends keyof Datos, TypeScript exige que el valor tenga el tipo de esa propiedad: poner('jornada', 'mucho') no compila, porque jornada es number. Los genéricos se estudian en el módulo 13; aquí basta con copiarla.

Texto, textarea y el valor vacío

En HTML, un <textarea> lleva el texto entre sus etiquetas. En React usa value, como cualquier otro campo. Si le pones hijos, React te avisa en consola con Use the defaultValue or value props instead of setting children on <textarea>.

El valor inicial de un campo de texto debe ser '', nunca undefined ni null. El motivo está en la sección 12.

Números: el campo devuelve texto

e.target.value siempre es una cadena, aunque el campo sea type="number". Esto sorprende a todo el mundo la primera vez. Si escribes 2300 y haces salario + 100, obtienes "2300100".

En el estado conviene guardar el texto y convertir al enviar. Si guardas un número con valueAsNumber, un campo vacío da NaN, y si haces Number('') da 0, de modo que el usuario no podría vaciar el campo: al borrar el último dígito aparecería un cero. Por eso salario es string en Datos. range es la excepción, porque siempre tiene un valor numérico válido.

Mientras escribes puede haber estados intermedios. Lo hemos comprobado: al teclear 1e en un type="number", e.target.value es '' y valueAsNumber es NaN, porque aún no es un número válido. Al seguir con 1e3, el valor es '1e3' y valueAsNumber es 1000. Tu validación debe contar con esos valores.

💡 Consejo: type="number" está pensado para cantidades. Para códigos que solo llevan cifras, como un teléfono o un código postal, usa type="text" con inputMode="numeric". Así el móvil muestra el teclado numérico y no pierdes ceros a la izquierda.

select: value en el padre

En HTML marcas la opción elegida con selected. En React pones value en el <select>, y es más cómodo, porque hay un único punto de verdad. Los valores de <option> llegan como texto: en el ejemplo, deptId es un número, así que el onChange convierte con Number.

Con multiple, value recibe un array, y para leer la selección no basta con e.target.value, que solo da la primera opción elegida. selectedOptions da todas, y Array.from con función de transformación las convierte a un array de cadenas.

checkbox, grupos y radio

Un checkbox suelto se controla con checked, y e.target.value no te sirve: vale 'on' esté o no marcado. Un grupo de casillas se guarda como array, y cada casilla calcula su checked con includes. Los radio son al revés: un único valor en el estado, y cada opción comprueba si es la elegida.

file: siempre no controlado

Un campo de fichero no se puede controlar, porque el navegador no permite que un script escriba la ruta de un fichero del usuario: sería un problema de seguridad. Por eso <input type="file"> no lleva value y se lee e.target.files, que es una lista de objetos File con name, size y type. En el ejemplo guardamos el File en el estado solo para mostrar su nombre. Para enviarlo a un servidor se mete en un FormData, que es el tema de la sección siguiente.


4. Campos no controlados y FormData

Un campo no controlado guarda su valor en el DOM, como en HTML puro. React lo pinta una vez y no vuelve a tocarlo. Tu código solo lee el valor cuando lo necesita.

A la izquierda, el estado de React manda sobre el input; a la derecha, el DOM guarda el dato y el código lo lee al enviar

Figura 3. La diferencia es dónde vive el dato: en el estado de React o en el DOM.

Para dar un valor inicial no puedes usar value, porque eso lo convertiría en controlado. Se usan defaultValue y defaultChecked:

import { useRef, useState } from 'react';

export default function BuscadorRef() {
  const campo = useRef<HTMLInputElement>(null);
  const [busqueda, setBusqueda] = useState('');

  return (
    <div className="tarjeta fila">
      <label htmlFor="buscar">Buscar</label>
      <input id="buscar" ref={campo} defaultValue="garcia" />
      <button onClick={() => setBusqueda(campo.current?.value ?? '')}>Aplicar</button>
      <p>Filtro aplicado: {busqueda || 'ninguno'}</p>
    </div>
  );
}

useRef da acceso directo al nodo del DOM. Aquí es un adelanto: el módulo 10 lo explica a fondo. Pero con formularios de verdad hay una herramienta mejor que una referencia por campo.

FormData: el formulario entero de golpe

FormData es una API del navegador, no de React. new FormData(formulario) recorre los campos del formulario y recoge los que tienen name, igual que haría el navegador al enviar. Es el camino natural para un formulario no controlado. En el ejemplo, la línea boton usa e.submitter, que explicamos justo después; si usas una versión anterior a React 19.3, cámbiala por e.nativeEvent.submitter.

import { useState, type SubmitEvent } from 'react';

export default function AltaCliente() {
  const [resultado, setResultado] = useState('');

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    const datos = new FormData(e.currentTarget);

    const cliente = {
      nombre: datos.get('nombre'),
      ciudad: datos.get('ciudad'),
      limiteCredito: Number(datos.get('limiteCredito')),
      boletin: datos.has('boletin'), // un checkbox sin marcar no aparece
      sectores: datos.getAll('sectores'), // varios campos con el mismo name
      boton: e.submitter?.getAttribute('value') ?? 'desconocido',
    };

    setResultado(JSON.stringify(cliente, null, 2));
    e.currentTarget.reset();
  }

  return (
    <form className="tarjeta" onSubmit={enviar}>
      <h2>Alta de cliente</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" name="nombre" required />
      </div>
      <div className="campo">
        <label htmlFor="ciudad">Ciudad</label>
        <select id="ciudad" name="ciudad" defaultValue="Valencia">
          <option>Madrid</option>
          <option>Valencia</option>
          <option>Sevilla</option>
        </select>
      </div>
      <div className="campo">
        <label htmlFor="limite">Límite de crédito</label>
        <input id="limite" name="limiteCredito" type="number" defaultValue="5000" />
      </div>
      <div className="campo">
        <label className="opcion">
          <input type="checkbox" name="boletin" defaultChecked />
          Recibir boletín
        </label>
      </div>
      <fieldset>
        <legend>Sectores</legend>
        {['Industria', 'Comercio', 'Servicios'].map((s) => (
          <label key={s} className="opcion">
            <input type="checkbox" name="sectores" value={s} />
            {s}
          </label>
        ))}
      </fieldset>
      <div className="fila">
        <button name="accion" value="guardar">Guardar</button>
        <button name="accion" value="borrador">Guardar borrador</button>
        <button type="reset">Limpiar</button>
      </div>
      {resultado && <pre>{resultado}</pre>}
    </form>
  );
}

Rellenando el nombre, marcando dos sectores y pulsando “Guardar borrador”, la salida es esta:

{
  "nombre": "Talleres Martín",
  "ciudad": "Valencia",
  "limiteCredito": 5000,
  "boletin": true,
  "sectores": [
    "Comercio",
    "Servicios"
  ],
  "boton": "borrador"
}
Método Devuelve Para qué
datos.get('nombre') FormDataEntryValue \| null Un valor. Es string o File, o null si no existe
datos.getAll('sectores') Array Todos los valores con ese name
datos.has('boletin') boolean Saber si está presente
Object.fromEntries(datos) Objeto Convertir a objeto, si no hay name repetidos

⚠️ Importante: FormData tiene tres trampas. Sin name, el campo no existe: es el motivo número uno de que get devuelva null. Un checkbox sin marcar no aparece, por eso se pregunta con has. Y todo llega como texto, de modo que limiteCredito hay que convertirlo con Number. Con Object.fromEntries pierdes además los valores repetidos: de sectores solo quedaría el último.

e.currentTarget.reset() vuelve a dejar cada campo en su defaultValue. Es la forma natural de limpiar un formulario no controlado: tras el envío, el nombre queda vacío y el límite vuelve a 5000.

e.currentTarget no sobrevive a un await

e.currentTarget solo es válido mientras se ejecuta el manejador. Si esperas una promesa y lo usas después, vale null. Lo hemos comprobado: tras un await, String(e.currentTarget) da null. Guárdalo antes en una variable:

async function enviar(e: SubmitEvent<HTMLFormElement>) {
  e.preventDefault();
  const formulario = e.currentTarget; // guárdalo antes del await
  const datos = new FormData(formulario);
  await guardar(datos);
  formulario.reset();
}

Saber qué botón se pulsó 🆕 19.3

🆕 Desde React 19.3. No existe en React 19.2 ni anteriores: allí e.submitter es undefined. Si usas una versión anterior: lee e.nativeEvent.submitter, que da el mismo botón en todas las versiones que hemos probado.

Un formulario puede tener varios botones de envío, cada uno con su name y value. e.submitter es el elemento que provocó el envío. Si envías con Intro, el navegador usa el primer botón de envío del formulario, de modo que en nuestro ejemplo boton vale 'guardar'.

new FormData(formulario) no incluye el botón pulsado. Por eso leemos su atributo por separado. También puedes pedirlo con el segundo argumento: new FormData(formulario, e.submitter).

defaultValue en campos numéricos 🔄 19.3

🔄 Cambia en React 19.3. Hasta React 19.2, si un <input type="number"> tiene el foco, cambiar defaultValue no actualiza su atributo value; en 19.3 sí lo hace, igual que en los demás tipos de campo. Si usas una versión anterior: no cambies defaultValue con el campo enfocado; para reiniciarlo con otro valor, cámbiale la key.

defaultValue es solo el valor inicial. Si cambia después, lo que el usuario ve no cambia, porque un campo no controlado es dueño de su valor. Solo se actualiza el atributo que usa reset(). Es un detalle menor, pero explica por qué no debes usar defaultValue para “actualizar” un campo.

Reiniciar un formulario

Controlados y no controlados se reinician de forma distinta. En un campo controlado, reset() o un botón type="reset" no cambian nada: React mantiene el atributo value igual que el estado, de modo que el campo “vuelve” al mismo valor que ya tenía. Debes restablecer el estado tú mismo en onReset. En uno no controlado hay un truco que ya viste en el módulo 05: cambiar la key hace que React lo desmonte y cree uno nuevo.

import { useState } from 'react';

function Controlado() {
  const [ciudad, setCiudad] = useState('Valencia');

  return (
    <form className="tarjeta" onReset={() => setCiudad('Valencia')}>
      <h2>Controlado: onReset</h2>
      <div className="campo">
        <label htmlFor="ciudad-c">Ciudad</label>
        <input id="ciudad-c" value={ciudad} onChange={(e) => setCiudad(e.target.value)} />
      </div>
      <button type="reset">Restaurar</button>
    </form>
  );
}

function NoControlado() {
  return (
    <div className="campo">
      <label htmlFor="ciudad-n">Ciudad</label>
      <input id="ciudad-n" defaultValue="Valencia" />
    </div>
  );
}

export default function Reinicios() {
  const [version, setVersion] = useState(0);

  return (
    <>
      <Controlado />
      <section className="tarjeta">
        <h2>No controlado: key nueva</h2>
        <NoControlado key={version} />
        <button type="button" onClick={() => setVersion((v) => v + 1)}>Empezar de nuevo</button>
      </section>
    </>
  );
}

Si escribes Cuenca y Soria y pulsas los dos botones, ambos campos vuelven a Valencia.


5. Controlado o no controlado: cómo elegir

No hay una respuesta única. La pregunta correcta es cuándo necesitas el valor. Si solo lo quieres al enviar, no hace falta que React se entere de cada tecla. Si la interfaz debe reaccionar mientras se escribe, necesitas el estado.

Necesitas Controlado No controlado
Leer el valor al enviar Sí Sí, y con menos código
Validar mientras se escribe Sí Difícil
Desactivar el botón según el contenido Sí Difícil
Transformar lo que se escribe Sí No
Que un campo dependa de otro Sí Difícil
Rellenar el formulario desde datos que llegan después Sí Con key
Subir un fichero No es posible Sí
Formulario largo sin lógica entre campos Mucho código Sí
Renders mínimos Render por tecla Sin renders al escribir

En una aplicación de gestión, donde casi todo formulario valida, depende de otros campos o edita datos existentes, lo habitual es usar controlados. Un buscador simple, un formulario de contacto o uno que se apoya en <form action> encajan bien como no controlados.

💡 Consejo: El render por tecla no suele ser un problema. React renderiza un formulario normal en mucho menos de lo que tardas en pulsar la siguiente tecla. No elijas no controlado por rendimiento hasta que lo hayas medido: lo haremos en el módulo 14. Con formularios muy grandes, React Hook Form, del módulo 21, se apoya en campos no controlados por este motivo.


6. Validación manual

Validar es comprobar los datos antes de aceptarlos. Tiene dos capas, y las dos son necesarias.

⚠️ Importante: La validación en el cliente no protege nada. Sirve para ayudar al usuario, porque cualquiera puede saltársela con las herramientas del navegador o llamando a la API directamente. La validación que de verdad cuenta es la del servidor. Por eso la API EMPRESA vuelve a comprobarlo todo y devuelve 422 si algo falla.

Validación nativa de HTML

El navegador ya sabe validar. Atributos como required, minLength, pattern, min, max y type="email" funcionan con React sin hacer nada más. Si algo falla, el navegador muestra su burbuja, enfoca el campo y no lanza submit. Para las reglas que HTML no cubre, como comprobar que dos campos coinciden, tienes setCustomValidity:

import { useState, type ChangeEvent, type SubmitEvent } from 'react';

export default function ClaveNativa() {
  const [enviado, setEnviado] = useState(false);

  function comprobarRepeticion(e: ChangeEvent<HTMLInputElement>) {
    const campo = e.target;
    const clave = campo.form?.elements.namedItem('clave') as HTMLInputElement;
    campo.setCustomValidity(campo.value === clave.value ? '' : 'Las contraseñas no coinciden');
  }

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    setEnviado(true);
  }

  return (
    <form className="tarjeta" onSubmit={enviar}>
      <h2>Validación nativa</h2>
      <div className="campo">
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
      </div>
      <div className="campo">
        <label htmlFor="nif">NIF</label>
        <input id="nif" name="nif" required pattern="[0-9]{8}[A-Z]" title="8 cifras y una letra mayúscula" />
      </div>
      <div className="campo">
        <label htmlFor="clave">Contraseña</label>
        <input id="clave" name="clave" type="password" required minLength={8} />
      </div>
      <div className="campo">
        <label htmlFor="repetir">Repite la contraseña</label>
        <input id="repetir" name="repetir" type="password" required onChange={comprobarRepeticion} />
      </div>
      <button>Registrar</button>
      {enviado && <p className="exito">Formulario válido</p>}
    </form>
  );
}

Con un NIF de tres cifras, validationMessage da el texto de la burbuja, y el manejador no llega a ejecutarse. El mensaje lo pone el navegador en su idioma: en nuestra prueba, con Chromium en inglés, es Please match the requested format. Cuando setCustomValidity recibe una cadena no vacía, el campo pasa a ser inválido, y hay que volver a llamarlo con '' para dejarlo válido.

La validación nativa es rápida de escribir y accesible por defecto, pero tiene límites: no controlas el aspecto de las burbujas, su texto depende del navegador y es incómodo mostrar varios errores a la vez. Por eso los formularios de una aplicación suelen llevar una validación propia.

Validación propia con una función pura

La idea central es sencilla: los errores no se guardan, se calculan. Una función pura recibe los datos y devuelve los errores. Así es imposible que datos y errores queden desincronizados, y es el mismo razonamiento del estado derivado.

Lo único que sí hay que guardar es qué campos ha tocado el usuario. No queremos llenar de rojo un formulario recién abierto.

import { useState, type ChangeEvent, type SubmitEvent } from 'react';

const CATEGORIAS = [
  { categoriaId: 2, nombre: 'Portátiles' },
  { categoriaId: 3, nombre: 'Monitores' },
];

interface Producto {
  nombre: string;
  categoriaId: string;
  precio: string;
  stock: string;
}
type Campo = keyof Producto;
type Errores = Partial<Record<Campo, string>>;

const VACIO: Producto = { nombre: '', categoriaId: '', precio: '', stock: '' };

// Función pura: mismos datos, mismos errores. Se prueba sin React
function validar(p: Producto): Errores {
  const errores: Errores = {};
  const precio = Number(p.precio);
  const stock = Number(p.stock);

  if (p.nombre.trim().length < 3) errores.nombre = 'Escribe al menos 3 caracteres';
  if (!p.categoriaId) errores.categoriaId = 'Elige una categoría';
  if (p.precio === '' || Number.isNaN(precio) || precio <= 0) errores.precio = 'El precio debe ser mayor que 0';
  if (p.stock === '' || !Number.isInteger(stock) || stock < 0) errores.stock = 'El stock debe ser un entero no negativo';
  return errores;
}

export default function AltaProducto() {
  const [producto, setProducto] = useState(VACIO);
  const [tocados, setTocados] = useState<Partial<Record<Campo, boolean>>>({});
  const [intentado, setIntentado] = useState(false);
  const [guardado, setGuardado] = useState('');

  // Los errores no se guardan: se calculan en cada render
  const errores = validar(producto);
  const errorVisible = (campo: Campo) => (tocados[campo] || intentado ? errores[campo] : undefined);

  function cambiar(e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
    const { name, value } = e.target;
    setProducto((previo) => ({ ...previo, [name]: value }));
    setGuardado('');
  }

  function marcarTocado(campo: Campo) {
    setTocados((previos) => ({ ...previos, [campo]: true }));
  }

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    const primero = Object.keys(errores)[0];

    if (primero) {
      setIntentado(true);
      (e.currentTarget.elements.namedItem(primero) as HTMLElement).focus();
      return;
    }
    setGuardado(`Producto ${producto.nombre} guardado`);
    setProducto(VACIO);
    setTocados({});
    setIntentado(false);
  }

  return (
    <form className="tarjeta" onSubmit={enviar} noValidate>
      <h2>Nuevo producto</h2>

      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input
          id="nombre"
          name="nombre"
          value={producto.nombre}
          onChange={cambiar}
          onBlur={() => marcarTocado('nombre')}
          aria-invalid={!!errorVisible('nombre')}
          aria-describedby="nombre-error"
        />
        <p id="nombre-error" className="error">{errorVisible('nombre')}</p>
      </div>

      <div className="campo">
        <label htmlFor="categoriaId">Categoría</label>
        <select
          id="categoriaId"
          name="categoriaId"
          value={producto.categoriaId}
          onChange={cambiar}
          onBlur={() => marcarTocado('categoriaId')}
          aria-invalid={!!errorVisible('categoriaId')}
          aria-describedby="categoria-error"
        >
          <option value="">Selecciona...</option>
          {CATEGORIAS.map((c) => (
            <option key={c.categoriaId} value={c.categoriaId}>{c.nombre}</option>
          ))}
        </select>
        <p id="categoria-error" className="error">{errorVisible('categoriaId')}</p>
      </div>

      <div className="campo">
        <label htmlFor="precio">Precio (€)</label>
        <input
          id="precio"
          name="precio"
          type="number"
          step="0.01"
          value={producto.precio}
          onChange={cambiar}
          onBlur={() => marcarTocado('precio')}
          aria-invalid={!!errorVisible('precio')}
          aria-describedby="precio-error"
        />
        <p id="precio-error" className="error">{errorVisible('precio')}</p>
      </div>

      <div className="campo">
        <label htmlFor="stock">Stock</label>
        <input
          id="stock"
          name="stock"
          type="number"
          value={producto.stock}
          onChange={cambiar}
          onBlur={() => marcarTocado('stock')}
          aria-invalid={!!errorVisible('stock')}
          aria-describedby="stock-error"
        />
        <p id="stock-error" className="error">{errorVisible('stock')}</p>
      </div>

      <button>Guardar</button>
      {guardado && <p className="exito" role="status">{guardado}</p>}
    </form>
  );
}

Formulario de producto con los cuatro campos en rojo y su mensaje de error debajo de cada uno

Figura 4. Tras intentar guardar, cada campo muestra su error, y el foco fue al primero con problemas.

Merece la pena repasar las piezas:

  • noValidate en el <form> desactiva las burbujas del navegador, porque los mensajes los ponemos nosotros. Sin él, un required impediría que se ejecutara nuestro enviar.
  • tocados se marca con onBlur, el evento de perder el foco, de modo que el error aparece cuando el usuario sale del campo.
  • intentado hace visibles todos los errores en cuanto se pulsa Guardar, incluidos los de campos que nunca se tocaron.
  • namedItem(primero) busca el campo por su name para enfocarlo. Object.keys respeta el orden de inserción, y por eso validar comprueba los campos en el orden en que aparecen en pantalla. En nuestra prueba, tras pulsar Guardar con todo vacío, el foco va a nombre.

Cuándo mostrar cada error

Momento Ventaja Inconveniente Úsalo para
Al escribir Respuesta inmediata Riñe al usuario antes de que acabe Contadores y requisitos de contraseña
Al salir del campo Equilibrado Necesita tocados La mayoría de los campos
Al enviar Mínimas interrupciones El error llega tarde Formularios muy cortos
Mezcla: al salir, y tras un error, al escribir La mejor experiencia Algo más de lógica Formularios importantes

El código del ejemplo usa la mezcla: una vez que el campo se ha tocado, el error se recalcula con cada tecla y desaparece en cuanto el dato es correcto. Es lo que espera el usuario, y sale gratis porque los errores se calculan en cada render.

💡 Consejo: No desactives el botón de envío mientras el formulario sea inválido. El usuario no sabrá qué le falta, y un lector de pantalla podría ni encontrar el botón. Deja que lo pulse, muestra los errores y lleva el foco al primero. Desactivarlo sí tiene sentido mientras se envía, para evitar envíos dobles.


7. Accesibilidad en formularios

Un formulario accesible funciona con teclado y con lector de pantalla. La buena noticia es que casi todo sale de usar bien el HTML. El módulo 24 profundiza; aquí va lo que todo formulario necesita.

Relaciones entre label, input y mensaje de error mediante htmlFor, id y aria-describedby

Figura 5. Tres atributos unen la etiqueta, el campo y su mensaje de error.

Necesidad Cómo Por qué
Nombrar el campo <label htmlFor={id}> Da nombre al campo y amplía la zona de clic
Agrupar opciones <fieldset> y <legend> El lector anuncia la pregunta antes de cada opción
Marcar un error aria-invalid={true} El lector anuncia que el valor no es válido
Asociar ayuda o error aria-describedby="id" El lector lee ese texto al enfocar el campo
Avisar sin mover el foco role="alert" o role="status" Se anuncia al aparecer. alert interrumpe, status espera
Mantener el foco Enfoca el primer error El usuario de teclado llega directo al problema
Autorrellenar autoComplete="email" El navegador y los gestores de contraseñas ayudan
Teclado en móvil inputMode="numeric" Muestra el teclado adecuado sin cambiar el tipo

⚠️ Importante: placeholder no sustituye a <label>. Desaparece al escribir, suele tener poco contraste y el lector de pantalla no lo trata siempre como nombre del campo. Y no uses el color como única señal de error: quien no distinga el rojo necesita también el texto.

En JSX los atributos aria-* se escriben con guion, tal como en HTML. aria-invalid={false} se convierte en el texto "false", y por eso aria-invalid={!!error} funciona en los dos sentidos.


8. useId: identificadores únicos

Hasta ahora hemos escrito los id a mano: id="nombre". Eso funciona hasta que un componente se usa dos veces en la misma página. Si creas un componente Campo con un id fijo, dos instancias tendrían el mismo identificador. Un id repetido es HTML inválido y, en la práctica, una etiqueta terminaría enfocando el campo de la otra instancia.

useId genera un identificador único y estable para cada instancia del componente:

import { useId, type ComponentProps } from 'react';

interface CampoProps extends ComponentProps<'input'> {
  etiqueta: string;
  ayuda?: string;
  error?: string;
}

function Campo({ etiqueta, ayuda, error, ...resto }: CampoProps) {
  const id = useId();
  const idAyuda = `${id}-ayuda`;
  const idError = `${id}-error`;
  const descripciones = [ayuda && idAyuda, error && idError].filter(Boolean).join(' ');

  return (
    <div className="campo">
      <label htmlFor={id}>{etiqueta}</label>
      <input
        id={id}
        aria-invalid={error ? true : undefined}
        aria-describedby={descripciones || undefined}
        {...resto}
      />
      {ayuda && <p id={idAyuda} className="ayuda">{ayuda}</p>}
      {error && <p id={idError} className="error">{error}</p>}
    </div>
  );
}

export default function DosFormularios() {
  return (
    <>
      <form className="tarjeta">
        <h2>Datos de facturación</h2>
        <Campo etiqueta="Email" type="email" name="email" ayuda="Aquí enviaremos las facturas" required />
      </form>
      <form className="tarjeta">
        <h2>Datos de envío</h2>
        <Campo etiqueta="Email" type="email" name="email" error="Ese email ya está en uso" defaultValue="ana.garcia@empresa.es" />
      </form>
    </>
  );
}

Dos tarjetas con un campo Email cada una: la primera con su texto de ayuda y la segunda con un error

Figura 6. El mismo componente Campo usado dos veces, cada uno con su identificador propio.

Los dos campos reciben _r_0_ y _r_1_, y sus descripciones _r_0_-ayuda y _r_1_-error. Un solo useId basta para varios elementos, porque añadimos sufijos.

El componente también enseña un patrón que se repite mucho: con ...resto, Campo admite cualquier atributo de <input>. ComponentProps<'input'> da el tipo de todos esos atributos, y lo veremos en el módulo 13.

⚠️ Importante: No uses useId para las key de una lista: esas deben salir de tus datos, como explica el módulo 06. Tampoco uses Math.random() para los id: cambiaría en cada render, y en el servidor daría un valor distinto al del navegador. useId da el mismo resultado en servidor y cliente, un motivo que verás en el módulo 23.

Formato del identificador 🔄 19.2

🔄 Cambia en React 19.2. useId genera _r_0_. En 19.1 generaba «r0», y en 19.0 y 18.3 generaba :r0:. Si usas una versión anterior: el hook funciona igual, pero si usas el identificador en un selector CSS o en querySelector, escápalo con CSS.escape(id), porque los dos puntos no son válidos en un selector sin escapar.

El cambio se hizo para que el identificador sirva directamente como selector y como nombre de CSS. Hemos ejecutado el mismo componente en cada versión:

Versión Resultado de useId
18.3.1 y 19.0.9 :r0:
19.1.10 «r0»
19.2.9 y 19.3.1 _r_0_

Trata el valor como algo opaco: no extraigas nada de él ni dependas de su forma. Si en una página hay varias aplicaciones de React, createRoot admite la opción identifierPrefix para que sus identificadores no choquen.


9. Enviar a la API

Hasta aquí los datos se quedaban en el navegador. Enviar de verdad añade tres estados que hay que mostrar: enviando, error y éxito. Además aparece un tipo de error nuevo: el que solo conoce el servidor, como un email repetido.

La API EMPRESA responde a un dato inválido con 422 y un objeto detalles que indica el error de cada campo:

{
  "status": 422,
  "mensaje": "Hay datos no válidos",
  "detalles": { "email": "Ese email ya está en uso" }
}

El departamento se elige de una lista fija, porque cargar datos al montar un componente necesita useEffect, que se estudia en el módulo 09. La llamada a fetch al enviar no lo necesita: se ejecuta en respuesta a un evento, no para sincronizarse con nada. Repasa fetch en el Curso de JavaScript si te hace falta.

import { useState, type ChangeEvent, type SubmitEvent } from 'react';

const API = 'http://localhost:3000/api';

const DEPARTAMENTOS = [
  { deptId: 20, nombre: 'CONTABILIDAD' },
  { deptId: 30, nombre: 'INFORMÁTICA' },
  { deptId: 40, nombre: 'VENTAS' },
];

const VACIO = { nombre: '', apellidos: '', email: '', oficio: '', salario: '', deptId: '30' };
type Campo = keyof typeof VACIO;
type Errores = Partial<Record<Campo, string>>;

// En una aplicación real el token sale de la sesión del usuario
async function iniciarSesion(): Promise<string> {
  const respuesta = await fetch(`${API}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email: 'ana.garcia@empresa.es', password: 'secreto' }),
  });
  const cuerpo = await respuesta.json();
  return cuerpo.token;
}

export default function AltaEmpleado() {
  const [datos, setDatos] = useState(VACIO);
  const [errores, setErrores] = useState<Errores>({});
  const [errorGeneral, setErrorGeneral] = useState('');
  const [enviando, setEnviando] = useState(false);
  const [creado, setCreado] = useState('');

  function cambiar(e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
    const { name, value } = e.target;
    setDatos((previos) => ({ ...previos, [name]: value }));
    // El error de servidor de este campo ya no vale: el usuario lo está corrigiendo
    setErrores((previos) => ({ ...previos, [name]: undefined }));
  }

  async function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    setEnviando(true);
    setErrores({});
    setErrorGeneral('');
    setCreado('');

    try {
      const token = await iniciarSesion();
      const respuesta = await fetch(`${API}/empleados`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
        body: JSON.stringify({
          ...datos,
          salario: Number(datos.salario), // la API exige número, el campo da texto
          deptId: Number(datos.deptId),
        }),
      });
      const cuerpo = await respuesta.json();

      if (respuesta.status === 422) {
        setErrores(cuerpo.detalles);
      } else if (!respuesta.ok) {
        setErrorGeneral(cuerpo.mensaje);
      } else {
        setCreado(`Empleado ${cuerpo.empId} creado`);
        setDatos(VACIO);
      }
    } catch {
      setErrorGeneral('No se ha podido conectar con el servidor');
    } finally {
      setEnviando(false);
    }
  }

  function campoTexto(campo: Campo, etiqueta: string, tipo = 'text') {
    return (
      <div className="campo">
        <label htmlFor={campo}>{etiqueta}</label>
        <input
          id={campo}
          name={campo}
          type={tipo}
          value={datos[campo]}
          onChange={cambiar}
          aria-invalid={!!errores[campo]}
          aria-describedby={`${campo}-error`}
        />
        <p id={`${campo}-error`} className="error">{errores[campo]}</p>
      </div>
    );
  }

  return (
    <form className="tarjeta" onSubmit={enviar} noValidate>
      <h2>Alta de empleado</h2>
      {campoTexto('nombre', 'Nombre')}
      {campoTexto('apellidos', 'Apellidos')}
      {campoTexto('email', 'Email', 'email')}
      {campoTexto('oficio', 'Oficio')}
      {campoTexto('salario', 'Salario', 'number')}

      <div className="campo">
        <label htmlFor="deptId">Departamento</label>
        <select id="deptId" name="deptId" value={datos.deptId} onChange={cambiar}>
          {DEPARTAMENTOS.map((d) => (
            <option key={d.deptId} value={d.deptId}>{d.nombre}</option>
          ))}
        </select>
      </div>

      <button disabled={enviando}>{enviando ? 'Guardando...' : 'Guardar'}</button>
      {errorGeneral && <p className="error" role="alert">{errorGeneral}</p>}
      {creado && <p className="exito" role="status">{creado}</p>}
    </form>
  );
}

Enviamos a propósito el formulario casi vacío, con el email de Ana García, que ya existe. Todos los mensajes de la figura vienen del servidor:

Formulario de alta de empleado con los errores devueltos por la API debajo de cada campo

Figura 7. Los errores del 422 aparecen junto a su campo, incluido el email repetido.

Con los datos corregidos, la API responde 201 y el formulario muestra Empleado 161 creado. El número es el siguiente identificador libre en esa ejecución de la API.

Observa cómo está resuelto cada problema:

  • Los errores de servidor sí se guardan en el estado. A diferencia de los de la sección 6, no se pueden calcular a partir de los datos: son una respuesta que llegó en un momento dado. Por eso cambiar borra el error del campo que se está corrigiendo.
  • Number(datos.salario). La API rechaza un salario en forma de cadena, y el campo da texto. La conversión se hace justo antes de enviar.
  • finally devuelve enviando a false tanto si ha ido bien como si ha fallado.
  • disabled={enviando} evita el doble envío al pulsar dos veces.
  • catch cubre el caso de que no se pueda conectar. fetch no rechaza la promesa con un 422 ni un 500: solo cuando no hay respuesta. Por eso comprobamos respuesta.status y respuesta.ok.

💡 Consejo: El ejemplo inicia sesión en cada envío para que quepa en un fichero, pero una aplicación real guarda el token al entrar. Las escrituras de la API exigen token: sin él, la respuesta es 401. El proyecto final, en el módulo 26, lo resuelve bien.


10. <form action>: enviar con una función 🆕 19.0

🆕 Desde React 19.0. No existe en React 18.3: allí React avisa con Invalid value for prop action, descarta la función y el navegador envía el formulario recargando la página. Si usas una versión anterior: usa onSubmit con e.preventDefault(), new FormData(e.currentTarget) y un estado enviando, como en la sección 9.

En React 19 el atributo action de <form> acepta una función. Esa función es una Action: recibe el FormData y puede ser asíncrona. React se encarga del preventDefault, de crear el FormData y de seguir el estado pendiente.

import { useState } from 'react';
import { useFormStatus } from 'react-dom';

const esperar = (ms: number) => new Promise((resolver) => setTimeout(resolver, ms));

function BotonGuardar() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Guardando...' : 'Guardar'}</button>;
}

export default function NotasReunion() {
  const [notas, setNotas] = useState<string[]>([]);

  async function guardar(datos: FormData) {
    await esperar(600); // aquí iría la llamada a la API
    setNotas((previas) => [...previas, String(datos.get('texto'))]);
  }

  return (
    <form className="tarjeta" action={guardar}>
      <h2>Notas de la reunión</h2>
      <div className="campo">
        <label htmlFor="texto">Nota</label>
        <input id="texto" name="texto" required />
      </div>
      <BotonGuardar />
      <ul>
        {notas.map((nota, i) => (
          <li key={i}>{nota}</li>
        ))}
      </ul>
    </form>
  );
}

No hay onSubmit, ni preventDefault, ni useState para el estado de envío. Al pulsar el botón, useFormStatus da pending: true: el botón se desactiva y muestra “Guardando…”. Cuando la Action termina, el <input> queda vacío sin que hayamos escrito reset().

Dos columnas con los pasos del envío: con onSubmit los escribes tú y con action los hace React

Figura 8. Con action, React asume los pasos que en onSubmit escribes a mano.

⚠️ Importante: useFormStatus debe llamarse en un componente hijo del <form>, no en el componente que lo renderiza. Es un fallo fácil de cometer: lo hemos probado llamándolo en el mismo componente, y pending se queda en false durante toda la Action, sin error ni aviso. Por eso BotonGuardar es un componente aparte. Se importa de react-dom, no de react.

Varias acciones con formAction

Un botón puede tener su propia Action con formAction, que sustituye a la del formulario. Es la alternativa a preguntar por submitter:

import { useState } from 'react';

export default function PedidoBotones() {
  const [registro, setRegistro] = useState('Sin acciones');

  function confirmar(datos: FormData) {
    setRegistro(`Pedido confirmado para el cliente ${datos.get('clienteId')}`);
  }

  function aplazar(datos: FormData) {
    setRegistro(`Pedido del cliente ${datos.get('clienteId')} aplazado`);
  }

  return (
    <form className="tarjeta" action={confirmar}>
      <h2>Pedido</h2>
      <div className="campo">
        <label htmlFor="clienteId">Cliente</label>
        <input id="clienteId" name="clienteId" defaultValue="1001" />
      </div>
      <div className="fila">
        <button>Confirmar</button>
        <button formAction={aplazar}>Aplazar</button>
      </div>
      <p>{registro}</p>
    </form>
  );
}

Escribiendo 1002 y pulsando “Aplazar”, la pantalla dice Pedido del cliente 1002 aplazado, y el campo vuelve a 1001. Es el reinicio automático que explicamos a continuación.

El reinicio automático

Cuando la Action termina, React reinicia el formulario. Esto tiene una consecuencia que conviene conocer de antemano: solo los campos no controlados se reinician. Un campo controlado conserva su valor, por las razones de la sección 4.

⚠️ Importante: El reinicio ocurre aunque tu Action haya detectado un error. Para React, una función que termina normalmente ha terminado bien. Lo hemos probado: una Action que simplemente acaba deja los no controlados con su defaultValue, devuelve el checkbox a su estado inicial y deja intacto el campo controlado. Si muestras “email repetido” y borras lo que escribió el usuario, le obligas a empezar de nuevo.

Tienes tres soluciones:

Solución Cómo Cuándo
Campos controlados value y onChange, como en el siguiente ejemplo Si necesitas otros usos del estado
Validar antes Atributos required, pattern, etc., que frenan el envío Reglas simples
Devolver el estado useActionState y defaultValue Patrón completo, en el módulo 16

Errores en una Action

Si una Action lanza una excepción, el error no aparece junto al formulario: sube al Error Boundary más cercano, que se explica en el módulo 15. Lo hemos comprobado: al lanzar un Error en una Action asíncrona, el Error Boundary lo captura y el formulario desaparece de la pantalla. Eso rara vez es lo que quieres para un error esperado como un dato inválido.

Por tanto, captura los errores esperados dentro de la Action y guárdalos en el estado:

import { useState } from 'react';

export default function AccionConError() {
  const [error, setError] = useState('');
  const [nombre, setNombre] = useState('');

  async function guardar(datos: FormData) {
    setError('');
    try {
      const respuesta = await fetch('http://localhost:3000/api/departamentos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ nombre: datos.get('nombre') }),
      });
      if (!respuesta.ok) {
        const cuerpo = await respuesta.json();
        setError(cuerpo.mensaje);
      }
    } catch {
      setError('No se ha podido conectar con el servidor');
    }
  }

  return (
    <form className="tarjeta" action={guardar}>
      <h2>Nuevo departamento</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" name="nombre" value={nombre} onChange={(e) => setNombre(e.target.value)} />
      </div>
      <button>Guardar</button>
      {error && <p className="error" role="alert">{error}</p>}
    </form>
  );
}

Como este ejemplo no envía token, la API responde 401, y la pantalla muestra Debes iniciar sesión. El nombre sigue escrito en el campo porque es controlado.

💡 Consejo: TypeScript exige que la función de action devuelva void o Promise<void>. Si devuelves { error: 'mal' }, no compila, y aunque compilara nadie recogería ese valor. Quien recoge lo que devuelve una Action es useActionState.

onSubmit o action

Criterio onSubmit <form action>
Versión Todas 19.0 o posterior
preventDefault Lo escribes tú Automático
Estado pendiente useState propio useFormStatus
Datos Estado o FormData FormData
Reinicio Si lo pides Automático en no controlados
Excepciones try/catch, o error sin capturar Suben al Error Boundary
Server Functions No Sí (módulo 23)

Ninguna de las dos es “la correcta”. onSubmit sigue siendo apropiado cuando el estado ya tiene los datos, y action encaja con no controlados y con Server Functions. El módulo 16 completa el tema con useActionState, useOptimistic y requestFormReset.

onReset tras una Action 🔄 19.3

🔄 Cambia en React 19.3. El reinicio automático tras una Action dispara onReset. En 19.0, 19.1 y 19.2, el formulario se reinicia sin disparar ese evento. Si usas una versión anterior: no pongas lógica de limpieza en onReset para este caso; ejecútala al final de la propia Action.

Hemos ejecutado el mismo formulario en todas las versiones: los campos se reinician siempre, pero el evento solo llega en 19.3. Si tu onReset restablece estado, como en el ejemplo Controlado de la sección 4, ahora también se ejecutará después de cada Action. Tenlo en cuenta al actualizar.


11. Formularios multipaso

Un formulario largo intimida. Dividirlo en pasos, como el alta de un empleado o la compra en una tienda, reduce esa carga. Hay tres reglas:

  1. Los datos viven en el componente padre, que no se desmonta. Los pasos solo se muestran y se ocultan.
  2. Cada paso valida solo sus campos, antes de dejarte avanzar.
  3. Un único envío real, al final, con todos los datos.

El componente Multipaso guarda paso y datos; los tres pasos dependen de él

Figura 9. Retroceder no pierde nada, porque los datos no están en los pasos.

import { useState, type ChangeEvent, type SubmitEvent } from 'react';

interface Alta {
  nombre: string;
  apellidos: string;
  email: string;
  oficio: string;
  salario: string;
  deptId: string;
}
type Campo = keyof Alta;
type Errores = Partial<Record<Campo, string>>;

const VACIA: Alta = { nombre: '', apellidos: '', email: '', oficio: '', salario: '', deptId: '30' };
const PASOS = ['Datos personales', 'Puesto', 'Revisión'];
const ES_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

// Cada paso valida solo sus campos
const VALIDADORES: ((a: Alta) => Errores)[] = [
  (a) => ({
    ...(!a.nombre.trim() && { nombre: 'El nombre es obligatorio' }),
    ...(!a.apellidos.trim() && { apellidos: 'Los apellidos son obligatorios' }),
    ...(!ES_EMAIL.test(a.email) && { email: 'El email no es válido' }),
  }),
  (a) => ({
    ...(!a.oficio.trim() && { oficio: 'El oficio es obligatorio' }),
    ...(!(Number(a.salario) > 0) && { salario: 'El salario debe ser mayor que cero' }),
  }),
  () => ({}),
];

// Fuera del componente: es una función estable, y solo se ejecuta al montar el título
const enfocar = (nodo: HTMLElement | null) => nodo?.focus();

export default function Multipaso() {
  const [paso, setPaso] = useState(0);
  const [alta, setAlta] = useState(VACIA);
  const [errores, setErrores] = useState<Errores>({});
  const [enviada, setEnviada] = useState(false);
  const ultimo = paso === PASOS.length - 1;

  function cambiar(e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
    const { name, value } = e.target;
    setAlta((previa) => ({ ...previa, [name]: value }));
    setErrores((previos) => ({ ...previos, [name]: undefined }));
  }

  function avanzar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    const nuevos = VALIDADORES[paso](alta);
    setErrores(nuevos);
    if (Object.keys(nuevos).length > 0) return;

    if (ultimo) setEnviada(true);
    else setPaso(paso + 1);
  }

  function campo(nombre: Campo, etiqueta: string, tipo = 'text') {
    return (
      <div className="campo">
        <label htmlFor={nombre}>{etiqueta}</label>
        <input
          id={nombre}
          name={nombre}
          type={tipo}
          value={alta[nombre]}
          onChange={cambiar}
          aria-invalid={!!errores[nombre]}
          aria-describedby={`${nombre}-error`}
        />
        <p id={`${nombre}-error`} className="error">{errores[nombre]}</p>
      </div>
    );
  }

  if (enviada) {
    return <p className="tarjeta exito" role="status">Alta de {alta.nombre} {alta.apellidos} enviada</p>;
  }

  return (
    <form className="tarjeta" onSubmit={avanzar} noValidate>
      <ol className="pasos">
        {PASOS.map((titulo, i) => (
          <li key={titulo} aria-current={i === paso ? 'step' : undefined}>{i + 1}. {titulo}</li>
        ))}
      </ol>

      <h3 key={paso} ref={enfocar} tabIndex={-1}>
        Paso {paso + 1} de {PASOS.length}: {PASOS[paso]}
      </h3>

      {paso === 0 && (
        <>
          {campo('nombre', 'Nombre')}
          {campo('apellidos', 'Apellidos')}
          {campo('email', 'Email', 'email')}
        </>
      )}

      {paso === 1 && (
        <>
          {campo('oficio', 'Oficio')}
          {campo('salario', 'Salario', 'number')}
          <div className="campo">
            <label htmlFor="deptId">Departamento</label>
            <select id="deptId" name="deptId" value={alta.deptId} onChange={cambiar}>
              <option value="20">CONTABILIDAD</option>
              <option value="30">INFORMÁTICA</option>
              <option value="40">VENTAS</option>
            </select>
          </div>
        </>
      )}

      {paso === 2 && (
        <dl>
          <dt>Empleado</dt>
          <dd>{alta.nombre} {alta.apellidos} ({alta.email})</dd>
          <dt>Puesto</dt>
          <dd>{alta.oficio}, {alta.salario} €, departamento {alta.deptId}</dd>
        </dl>
      )}

      <div className="fila">
        {paso > 0 && (
          <button type="button" onClick={() => setPaso(paso - 1)}>Anterior</button>
        )}
        <button type="submit">{ultimo ? 'Enviar alta' : 'Siguiente'}</button>
      </div>
    </form>
  );
}

Paso 1 con los tres campos vacíos y sus mensajes de error

Figura 10. El indicador marca el paso actual, y no se puede avanzar con errores.

Paso de revisión con los datos del empleado y los botones Anterior y Enviar alta

Figura 11. El último paso resume los datos antes del envío definitivo.

Decisiones de diseño que vale la pena copiar:

  • Un solo <form> y un solo botón de envío. avanzar es el onSubmit para todos los pasos: valida y decide si sigue o termina. Así Intro funciona en cada paso. En la prueba, Intro en el email lleva al paso 2.
  • Anterior es type="button", así que no envía ni valida. El usuario puede retroceder aunque el paso actual tenga errores.
  • VALIDADORES es un array indexado por paso. Añadir un paso es añadir una función. La expresión ...(condición && { campo: 'mensaje' }) añade la propiedad solo si la condición se cumple, porque esparcir false no añade nada.
  • El foco se mueve al título del paso. Sin esto, al cambiar de paso, el foco se pierde: quien navega con teclado o lector de pantalla no se entera del cambio. La key={paso} crea un <h3> nuevo en cada paso, ref={enfocar} lo enfoca y tabIndex={-1} permite enfocar un título. En la prueba, tras avanzar, el elemento activo es Paso 2 de 3: Puesto. Pasar una función a ref se explica en el módulo 10.
  • aria-current="step" indica el paso actual al lector de pantalla. El CSS se apoya en el mismo atributo, de modo que no se puede olvidar uno sin el otro.

⚠️ Importante: enfocar está fuera del componente a propósito. Una función definida dentro es nueva en cada render, y React llamaría a la ref en cada tecla: el foco saltaría al título mientras escribes.

El botón que envía solo

Hay una versión del botón final que parece correcta y tiene un fallo muy desconcertante:

import { useState, type SubmitEvent } from 'react';

export default function BotonQueCambia() {
  const [paso, setPaso] = useState(1);
  const [envios, setEnvios] = useState(0);

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    setEnvios((n) => n + 1);
  }

  return (
    <form className="tarjeta" onSubmit={enviar}>
      <h2>Paso {paso} de 2</h2>
      {paso === 1 ? (
        <button type="button" onClick={() => setPaso(2)}>Siguiente</button>
      ) : (
        <button type="submit">Enviar</button>
      )}
      <p>Envíos: {envios}</p>
    </form>
  );
}

Al pulsar “Siguiente”, el formulario se envía: nuestra prueba muestra Paso 2 de 2 y Envíos: 1, sin que nadie haya pulsado “Enviar”. Lo que ocurre es esto. Los dos botones ocupan la misma posición del árbol y son del mismo tipo de elemento, así que React reutiliza el mismo nodo del DOM, como explica el módulo 05. Durante el clic, setPaso(2) provoca un render, React cambia type a submit, y cuando el navegador acaba de procesar el clic ve un botón de envío y envía.

Hay dos soluciones. Una es la de Multipaso: un único botón type="submit" que cambia de texto, y toda la lógica en onSubmit. La otra es dar a cada botón una key distinta, para que React cree un nodo nuevo:

{paso === 1 ? (
  <button key="siguiente" type="button" onClick={() => setPaso(2)}>Siguiente</button>
) : (
  <button key="enviar" type="submit">Enviar</button>
)}

Ocultar o desmontar

En Multipaso, los pasos se desmontan al cambiar, y funciona porque los campos son controlados con el estado en el padre. Con campos no controlados, desmontarlos destruiría lo escrito, porque el dato vive en el DOM. En ese caso oculta el paso con el atributo hidden: el campo sigue en el DOM y FormData lo incluye al enviar.


12. Errores frecuentes y depuración

Estos son los avisos reales de la consola de React 19.3.1 y su significado.

Aviso en consola Causa Solución
You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. value sin onChange: el campo no se puede editar Añade onChange, usa defaultValue o pon readOnly
You provided a `checked` prop to a form field without an `onChange` handler. Lo mismo con checked Añade onChange o usa defaultChecked
A component is changing an uncontrolled input to be controlled. value pasó de undefined a tener valor Inicia el estado con ''
value prop on input should not be null. value={null}, por ejemplo con un dato de la API Usa value={dato ?? ''}
contains an input of type text with both value and defaultValue props Ambas props a la vez Elige una
Use the defaultValue or value props instead of setting children on <textarea>. Texto entre las etiquetas Usa value

El segundo caso es el más habitual, y suele aparecer así:

// ❌ MAL: 'nombre' empieza en undefined
const [nombre, setNombre] = useState<string>();
<input value={nombre} onChange={(e) => setNombre(e.target.value)} />

// ✅ BIEN: siempre es una cadena
const [nombre, setNombre] = useState('');

Mientras value es undefined, React considera que el campo es no controlado. Cuando llega el primer carácter pasa a controlado, y React te avisa porque un campo debe ser una cosa u otra durante toda su vida. Ocurre igual si un objeto de estado no tiene la propiedad: value={ficha.telefono} con telefono ausente.

Otros fallos que no dan aviso:

Síntoma Causa Solución
La página se recarga al enviar Falta e.preventDefault() Añádelo como primera línea
“Cancelar” envía el formulario <button> es submit por defecto Pon type="button"
No puedo escribir en el campo onChange no actualiza el estado, o el name no coincide Revisa el name y mira el estado en DevTools
Al cambiar un campo se borran los demás setDatos({ [name]: value }) sin copiar Usa { ...previos, [name]: value }
FormData devuelve null El campo no tiene name Añade name
Un checkbox sin marcar no aparece en FormData Es el comportamiento de HTML Usa has
"2300" + 100 da "2300100" Todo campo devuelve texto Convierte con Number
Cannot read properties of null (reading 'reset') e.currentTarget tras un await Guárdalo antes del await
pending siempre es false useFormStatus en el mismo componente que el <form> Muévelo a un componente hijo
El formulario se borra tras un error Reinicio automático de la Action Usa campos controlados
El cursor salta al final Transformas el valor en onChange Usa CSS, o transforma al salir del campo

💡 Consejo: Para depurar un formulario, abre React DevTools y selecciona el componente: verás el estado actualizándose con cada tecla. Otra opción rápida es añadir <pre>{JSON.stringify(datos, null, 2)}</pre> debajo del formulario, como en nuestros ejemplos, y quitarlo después.


13. Ejercicios

Ejercicio 1: Alta de departamento

Crea un formulario controlado con nombre, localidad y presupuesto. Al enviar debe:

  • Mostrar un error si el nombre está vacío.
  • Guardar el departamento en una lista, con el nombre en mayúsculas y sin espacios sobrantes.
  • Guardar presupuesto como número, o null si se deja vacío.
  • Limpiar el formulario.
Ver solución
import { useState, type ChangeEvent, type SubmitEvent } from 'react';

interface Departamento {
  nombre: string;
  localidad: string;
  presupuesto: number | null;
}

const VACIO = { nombre: '', localidad: '', presupuesto: '' };

export default function AltaDepartamento() {
  const [datos, setDatos] = useState(VACIO);
  const [error, setError] = useState('');
  const [lista, setLista] = useState<Departamento[]>([]);

  function cambiar(e: ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    setDatos((previos) => ({ ...previos, [name]: value }));
  }

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    if (!datos.nombre.trim()) {
      setError('El nombre es obligatorio');
      return;
    }
    const nuevo: Departamento = {
      nombre: datos.nombre.trim().toUpperCase(),
      localidad: datos.localidad.trim(),
      presupuesto: datos.presupuesto === '' ? null : Number(datos.presupuesto),
    };
    setLista((previa) => [...previa, nuevo]);
    setDatos(VACIO);
    setError('');
  }

  return (
    <form className="tarjeta" onSubmit={enviar}>
      <h2>Nuevo departamento</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" name="nombre" value={datos.nombre} onChange={cambiar} />
        {error && <p className="error">{error}</p>}
      </div>
      <div className="campo">
        <label htmlFor="localidad">Localidad</label>
        <input id="localidad" name="localidad" value={datos.localidad} onChange={cambiar} />
      </div>
      <div className="campo">
        <label htmlFor="presupuesto">Presupuesto</label>
        <input id="presupuesto" name="presupuesto" type="number" min="0" value={datos.presupuesto} onChange={cambiar} />
      </div>
      <button>Añadir</button>
      <ul>
        {lista.map((d) => (
          <li key={d.nombre}>
            {d.nombre} ({d.localidad || 'sin localidad'}): {d.presupuesto ?? 'sin presupuesto'}
          </li>
        ))}
      </ul>
    </form>
  );
}

Hay dos tipos para el mismo concepto: VACIO guarda lo que hay en los campos, con texto, y Departamento guarda el dato ya limpio. Esa separación es normal, y evita pelearse con NaN mientras el usuario escribe. Al añadir LOGÍSTICA, Bilbao y 120000, la lista muestra LOGÍSTICA (Bilbao): 120000.

Ejercicio 2: Preferencias con límite

Crea un formulario con radio para el turno, un grupo de checkbox para tecnologías y un select para el idioma. Permite como máximo tres tecnologías: al llegar a tres, las demás casillas se desactivan. Muestra debajo un resumen en vivo.

Ver solución
import { useState } from 'react';

const TURNOS = ['Mañana', 'Tarde', 'Noche'];
const TECNOLOGIAS = ['React', 'Java', 'SQL', 'Python'];
const IDIOMAS = [
  { codigo: 'es', nombre: 'Español' },
  { codigo: 'en', nombre: 'Inglés' },
];
const MAXIMO = 3;

export default function Preferencias() {
  const [turno, setTurno] = useState('Mañana');
  const [tecnologias, setTecnologias] = useState<string[]>([]);
  const [idioma, setIdioma] = useState('es');

  function alternar(tecnologia: string) {
    setTecnologias((previas) =>
      previas.includes(tecnologia)
        ? previas.filter((t) => t !== tecnologia)
        : [...previas, tecnologia],
    );
  }

  const lleno = tecnologias.length >= MAXIMO;

  return (
    <form className="tarjeta">
      <h2>Preferencias</h2>
      <fieldset>
        <legend>Turno</legend>
        {TURNOS.map((t) => (
          <label key={t} className="opcion">
            <input type="radio" name="turno" value={t} checked={turno === t} onChange={() => setTurno(t)} />
            {t}
          </label>
        ))}
      </fieldset>
      <fieldset>
        <legend>Tecnologías (máximo {MAXIMO})</legend>
        {TECNOLOGIAS.map((t) => {
          const marcada = tecnologias.includes(t);
          return (
            <label key={t} className="opcion">
              <input
                type="checkbox"
                value={t}
                checked={marcada}
                disabled={lleno && !marcada}
                onChange={() => alternar(t)}
              />
              {t}
            </label>
          );
        })}
      </fieldset>
      <div className="campo">
        <label htmlFor="idioma">Idioma</label>
        <select id="idioma" value={idioma} onChange={(e) => setIdioma(e.target.value)}>
          {IDIOMAS.map((i) => (
            <option key={i.codigo} value={i.codigo}>{i.nombre}</option>
          ))}
        </select>
      </div>
      <p className="ayuda">
        Turno {turno}, {tecnologias.length ? tecnologias.join(', ') : 'sin tecnologías'}, idioma {idioma}
      </p>
    </form>
  );
}

lleno se calcula, no se guarda. Las casillas ya marcadas siguen activas, gracias a lleno && !marcada, para que el usuario pueda desmarcarlas. Con Noche, React, Java y Python, SQL queda desactivada y el resumen dice Turno Noche, React, Java, Python, idioma en.

Ejercicio 3: Contador de caracteres

Crea un textarea para un comentario de 100 caracteres como máximo. Muestra “Quedan N caracteres” o, si te pasas, “Te sobran N caracteres” en rojo. El botón se desactiva si el texto está vacío o se pasa del límite. No uses maxLength: queremos dejar que el usuario se pase y que vea cuánto debe recortar.

Ver solución
import { useState } from 'react';

const LIMITE = 100;
const AVISO = 90;

export default function Comentario() {
  const [texto, setTexto] = useState('');

  // Todo esto es estado derivado: se calcula, no se guarda
  const longitud = texto.length;
  const restantes = LIMITE - longitud;
  const clase = restantes < 0 ? 'error' : longitud >= AVISO ? 'exito' : 'ayuda';
  const invalido = texto.trim() === '' || restantes < 0;

  return (
    <form className="tarjeta" onSubmit={(e) => e.preventDefault()}>
      <div className="campo">
        <label htmlFor="comentario">Comentario del pedido</label>
        <textarea
          id="comentario"
          rows={3}
          value={texto}
          onChange={(e) => setTexto(e.target.value)}
          aria-describedby="contador"
          aria-invalid={restantes < 0}
        />
        <p id="contador" className={clase}>
          {restantes >= 0 ? `Quedan ${restantes} caracteres` : `Te sobran ${-restantes} caracteres`}
        </p>
      </div>
      <button disabled={invalido}>Guardar</button>
    </form>
  );
}

Solo hay un useState. Todo lo demás se deriva del texto, de modo que nada puede quedar desincronizado. trim hace que tres espacios cuenten como vacío. Aquí sí desactivamos el botón, porque el contador explica claramente qué falta.

Ejercicio 4: Encuesta con FormData

Crea un formulario no controlado con nombre, un grupo de checkbox de áreas, un radio de contacto preferido y un checkbox de “es urgente”. Sin ningún useState para los campos, muestra un JSON con los datos al enviar y limpia el formulario.

Ver solución
import { useState, type SubmitEvent } from 'react';

export default function Encuesta() {
  const [resumen, setResumen] = useState('');

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    const formulario = e.currentTarget;
    const datos = new FormData(formulario);

    setResumen(JSON.stringify({
      nombre: datos.get('nombre'),
      areas: datos.getAll('areas'),
      contacto: datos.get('contacto'),
      urgente: datos.has('urgente'),
    }, null, 2));
    formulario.reset();
  }

  return (
    <form className="tarjeta" onSubmit={enviar}>
      <h2>Petición de contacto</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" name="nombre" required />
      </div>
      <fieldset>
        <legend>Áreas</legend>
        {['Ventas', 'Soporte', 'Facturación'].map((a) => (
          <label key={a} className="opcion">
            <input type="checkbox" name="areas" value={a} />
            {a}
          </label>
        ))}
      </fieldset>
      <fieldset>
        <legend>Contacto preferido</legend>
        <label className="opcion">
          <input type="radio" name="contacto" value="email" defaultChecked /> Correo
        </label>
        <label className="opcion">
          <input type="radio" name="contacto" value="telefono" /> Teléfono
        </label>
      </fieldset>
      <div className="campo">
        <label className="opcion">
          <input type="checkbox" name="urgente" /> Es urgente
        </label>
      </div>
      <button>Enviar</button>
      {resumen && <pre>{resumen}</pre>}
    </form>
  );
}

Con Luis, Ventas, Soporte y Teléfono, la salida es:

{
  "nombre": "Luis",
  "areas": [
    "Ventas",
    "Soporte"
  ],
  "contacto": "telefono",
  "urgente": false
}

getAll da el array, get el valor del radio elegido y has indica si la casilla estaba marcada. useState solo se usa para el resultado que se muestra.

Ejercicio 5: Registro con validación cruzada

Crea un registro con email, contraseña y repetir contraseña. La contraseña debe tener al menos 8 caracteres y una cifra, y las dos contraseñas deben coincidir. Usa una función pura validar. Muestra los errores al salir del campo, y al enviar enfoca el primer error.

Ver solución
import { useState, type ChangeEvent, type SubmitEvent } from 'react';

const VACIO = { email: '', clave: '', repetir: '' };
type Campo = keyof typeof VACIO;
type Errores = Partial<Record<Campo, string>>;

function validar(d: typeof VACIO): Errores {
  const errores: Errores = {};
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(d.email)) errores.email = 'El email no es válido';
  if (d.clave.length < 8 || !/\d/.test(d.clave)) errores.clave = 'Mínimo 8 caracteres y una cifra';
  if (d.repetir !== d.clave) errores.repetir = 'Las contraseñas no coinciden';
  return errores;
}

export default function Registro() {
  const [datos, setDatos] = useState(VACIO);
  const [tocados, setTocados] = useState<Partial<Record<Campo, boolean>>>({});
  const [hecho, setHecho] = useState(false);

  const errores = validar(datos);

  function cambiar(e: ChangeEvent<HTMLInputElement>) {
    const { name, value } = e.target;
    setDatos((previos) => ({ ...previos, [name]: value }));
  }

  function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    setTocados({ email: true, clave: true, repetir: true });
    const primero = Object.keys(errores)[0];
    if (primero) {
      (e.currentTarget.elements.namedItem(primero) as HTMLElement).focus();
      return;
    }
    setHecho(true);
  }

  function campo(nombre: Campo, etiqueta: string, tipo: string) {
    const error = tocados[nombre] ? errores[nombre] : undefined;
    return (
      <div className="campo">
        <label htmlFor={nombre}>{etiqueta}</label>
        <input
          id={nombre}
          name={nombre}
          type={tipo}
          value={datos[nombre]}
          onChange={cambiar}
          onBlur={() => setTocados((t) => ({ ...t, [nombre]: true }))}
          aria-invalid={!!error}
          aria-describedby={`${nombre}-error`}
        />
        <p id={`${nombre}-error`} className="error">{error}</p>
      </div>
    );
  }

  return (
    <form className="tarjeta" onSubmit={enviar} noValidate>
      <h2>Crear cuenta</h2>
      {campo('email', 'Email', 'email')}
      {campo('clave', 'Contraseña', 'password')}
      {campo('repetir', 'Repite la contraseña', 'password')}
      <button>Crear cuenta</button>
      {hecho && <p className="exito" role="status">Cuenta creada</p>}
    </form>
  );
}

La regla de repetir compara dos campos, y funciona sin esfuerzo porque validar recibe el objeto entero. En este caso enviar marca todos como tocados en lugar de usar intentado: es una alternativa igual de válida. errores se calculó en el render, de modo que en enviar ya está disponible aunque setTocados aún no haya tenido efecto.

Ejercicio 6: Componente de campo reutilizable

Crea un componente CampoObligatorio que reciba etiqueta, ayuda opcional y cualquier atributo de <input>. Debe generar su propio id, mostrar un asterisco en la etiqueta y conectar la ayuda con aria-describedby, solo si la hay. Úsalo tres veces en un formulario.

Ver solución
import { useId, type ComponentProps } from 'react';

interface CampoProps extends ComponentProps<'input'> {
  etiqueta: string;
  ayuda?: string;
}

function CampoObligatorio({ etiqueta, ayuda, ...resto }: CampoProps) {
  const id = useId();
  const idAyuda = `${id}-ayuda`;

  return (
    <div className="campo">
      <label htmlFor={id}>{etiqueta} *</label>
      <input
        id={id}
        aria-required
        aria-describedby={ayuda ? idAyuda : undefined}
        {...resto}
      />
      {ayuda && <p id={idAyuda} className="ayuda">{ayuda}</p>}
    </div>
  );
}

export default function FichaProveedor() {
  return (
    <form className="tarjeta">
      <h2>Proveedor</h2>
      <CampoObligatorio etiqueta="Empresa" name="empresa" ayuda="Nombre fiscal completo" />
      <CampoObligatorio etiqueta="Teléfono" name="telefono" type="tel" inputMode="tel" autoComplete="tel" />
      <CampoObligatorio etiqueta="Email" name="email" type="email" autoComplete="email" />
    </form>
  );
}

Los campos reciben _r_0_, _r_1_ y _r_2_, y solo el primero lleva aria-describedby. Hacer clic en “Empresa” enfoca su campo, lo que confirma que la conexión funciona. {...resto} va al final para que quien usa el componente pueda sobrescribir atributos. Fíjate en que aria-required sin valor equivale a true, y en que el asterisco no sustituye a ese atributo: el asterisco se ve, y el atributo se anuncia.

Ejercicio 7: Inicio de sesión contra la API

Crea un login contra POST /api/auth/login. Debe mostrar “Entrando…” mientras espera, el mensaje de la API si falla y vaciar la contraseña en ese caso. Si entra, muestra “Hola, nombre”. Usuario de prueba: ana.garcia@empresa.es, contraseña secreto.

Ver solución
import { useState, type SubmitEvent } from 'react';

const API = 'http://localhost:3000/api';

export default function Login() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [enviando, setEnviando] = useState(false);
  const [usuario, setUsuario] = useState('');

  async function enviar(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    setEnviando(true);
    setError('');

    try {
      const respuesta = await fetch(`${API}/auth/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      const cuerpo = await respuesta.json();

      if (respuesta.ok) {
        setUsuario(cuerpo.usuario.nombre);
      } else {
        setError(cuerpo.mensaje);
        setPassword(''); // la contraseña fallida no se deja en el campo
      }
    } catch {
      setError('No se ha podido conectar con el servidor');
    } finally {
      setEnviando(false);
    }
  }

  if (usuario) return <p className="tarjeta exito" role="status">Hola, {usuario}</p>;

  return (
    <form className="tarjeta" onSubmit={enviar}>
      <h2>Iniciar sesión</h2>
      <div className="campo">
        <label htmlFor="email">Email</label>
        <input id="email" type="email" required autoComplete="username" value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div className="campo">
        <label htmlFor="password">Contraseña</label>
        <input id="password" type="password" required autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
      </div>
      <button disabled={enviando}>{enviando ? 'Entrando...' : 'Entrar'}</button>
      {error && <p className="error" role="alert">{error}</p>}
    </form>
  );
}

Con una contraseña incorrecta, la API responde Email o contraseña incorrectos. Con la buena, la pantalla muestra Hola, Ana García. autoComplete con username y current-password permite que los gestores de contraseñas reconozcan el formulario. El mensaje no aclara si falló el email o la contraseña, y es a propósito: así un atacante no puede averiguar qué cuentas existen.

Ejercicio 8: Dos acciones sobre un formulario 🆕 19.0

🆕 Desde React 19.0. No existe en React 18.3. Si usas una versión anterior: usa onSubmit, un solo manejador y e.nativeEvent.submitter para distinguir el botón.

Crea un formulario de departamento con <form action>. El botón “Publicar” usa la Action del formulario y “Guardar borrador” usa formAction. Ambos tardan 300 ms y añaden una línea a una lista. Desactiva los botones mientras se envía, y haz que el nombre sea obligatorio.

Ver solución
import { useState } from 'react';
import { useFormStatus } from 'react-dom';

const esperar = (ms: number) => new Promise((resolver) => setTimeout(resolver, ms));

function Botones({ onBorrador }: { onBorrador: (datos: FormData) => Promise<void> }) {
  const { pending } = useFormStatus();
  return (
    <div className="fila">
      <button disabled={pending}>Publicar</button>
      <button formAction={onBorrador} disabled={pending}>Guardar borrador</button>
    </div>
  );
}

export default function DepartamentoConAcciones() {
  const [entradas, setEntradas] = useState<string[]>([]);

  async function publicar(datos: FormData) {
    await esperar(300);
    setEntradas((previas) => [...previas, `${datos.get('nombre')} (publicado)`]);
  }

  async function guardarBorrador(datos: FormData) {
    await esperar(300);
    setEntradas((previas) => [...previas, `${datos.get('nombre')} (borrador)`]);
  }

  return (
    <form className="tarjeta" action={publicar}>
      <h2>Nuevo departamento</h2>
      <div className="campo">
        <label htmlFor="nombre">Nombre</label>
        <input id="nombre" name="nombre" required />
      </div>
      <Botones onBorrador={guardarBorrador} />
      <ul>
        {entradas.map((entrada) => (
          <li key={entrada}>{entrada}</li>
        ))}
      </ul>
    </form>
  );
}

useFormStatus obliga a tener un componente hijo, por eso existe Botones. El atributo required hace que el navegador detenga el envío antes de que se ejecute ninguna Action: al pulsar con el campo vacío, la lista sigue sin elementos, y por eso el reinicio automático no borra nada que importe. Tras COMPRAS y CALIDAD, la lista muestra COMPRAS (publicado) y CALIDAD (borrador).

Ejercicio 9 (propuesto): Asistente de pedido

Crea un asistente de tres pasos para un pedido de la empresa: cliente (select con tres clientes de ejemplo y fecha), líneas (producto y cantidad, con botón para añadir y quitar líneas) y revisión con el total. Cada paso valida antes de avanzar: al menos una línea y cantidades enteras positivas. Debe poder volver atrás sin perder datos.

Ejercicio 10 (propuesto): Selector de ficheros

Crea un campo para adjuntar varios PDF con multiple y accept. Muestra una lista con el nombre y el tamaño en KB de cada uno. Rechaza los mayores de 2 MB con un mensaje, y añade un botón “Quitar” para cada fichero. Piensa en por qué, al quitar uno, necesitas guardar los File en tu propio estado.

Ejercicio 11 (propuesto): Edición de empleado

Crea un formulario con PUT /api/empleados/100. Rellena los campos con un objeto de ejemplo escrito en el código. Usa campos controlados, muestra un aviso “Tienes cambios sin guardar” comparando los datos con los originales, y añade un botón que deshaga los cambios. El indicador debe ser estado derivado. Cuando acabes el módulo 09, carga el empleado desde la API.


Resumen

Concepto API Idea clave
Enviar onSubmit, e.preventDefault() Evita la recarga. Cubre el clic y la tecla Intro
Tipo del evento SubmitEvent<HTMLFormElement> Importado de react; FormEvent está obsoleto en @types/react 19.3
Campo controlado value y onChange El estado es la fuente de verdad
Formulario con objeto { ...previos, [name]: value } Una función para todos los campos
Casillas checked, e.target.checked Un booleano o un array, según sean sueltas o en grupo
Selección múltiple value={[]}, selectedOptions value recibe un array
Campo numérico valueAsNumber, Number() e.target.value siempre es texto
Ficheros e.target.files Solo pueden ser no controlados
Campo no controlado defaultValue, defaultChecked, ref El dato vive en el DOM
Datos del formulario FormData: get, getAll, has Sin name no hay dato. Todo llega como texto
Botón pulsado 🆕 19.3 e.submitter Antes, e.nativeEvent.submitter
Reinicio form.reset(), onReset, key Los controlados se reinician desde el estado
Validación nativa required, pattern, setCustomValidity Rápida, pero con menos control
Validación propia Función pura, noValidate, tocados Los errores se calculan, no se guardan
Accesibilidad label, fieldset, aria-invalid, aria-describedby Cada campo con nombre y sus errores conectados
Identificadores useId Únicos por instancia. El formato cambió en 19.1 y 19.2
Envío a la API fetch, estado enviando Los errores de servidor sí se guardan en el estado
Action 🆕 19.0 <form action={fn}>, formAction Recibe FormData y reinicia los no controlados al acabar
Estado pendiente 🆕 19.0 useFormStatus Se llama en un componente hijo del <form>
Multipaso Estado en el padre, validadores por paso Un solo <form>. Mueve el foco al cambiar de paso