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,textareay fichero - Usar campos no controlados con
defaultValue, referencias yFormData - 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-invalidyaria-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>yformAction, 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 deuseId(19.1 y 19.2),submitter,onResettras una Action ydefaultValueen 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.
onSubmitva en el<form>, no en elonClickdel botón. Si escuchas el clic, pulsar Intro dentro del campo no hace lo que el usuario espera. El eventosubmitcubre las dos formas de enviar, y además solo salta si la validación de HTML ha pasado.htmlForconecta la etiqueta con el campo. En JSXfores una palabra reservada de JavaScript, igual queclass. El módulo 02 explica por qué.<button>dentro de un formulario es de tiposubmitsi no dices otra cosa. Un botón “Cancelar” sintype="button"envía el formulario. Es uno de los errores más repetidos.
💡 Consejo:
SubmitEventes el tipo que importas dereact, no elSubmitEventglobal del navegador. En@types/react19.3.0, la versión de este curso, el antiguoFormEventaparece marcado como obsoleto, y su comentario te dirige aChangeEvent,InputEventoSubmitEvent. Si tu versión de@types/reacttodavía no exportaSubmitEvent, escribeReact.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)} />
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
valueel navegador coloca el cursor al final. Lo hemos probado en Chromium: con12ABescrito, colocamos el cursor tras el1y tecleamosz. El campo muestra1Z2AB, pero el cursor queda en la posición 5, y la siguiente tecla,y, produce1Z2ABYy no1ZY2AB. 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
VACIOoFICHA_VACIAfuera 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>
);
}

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, usatype="text"coninputMode="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.
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:
FormDatatiene tres trampas. Sinname, el campo no existe: es el motivo número uno de quegetdevuelvanull. Uncheckboxsin marcar no aparece, por eso se pregunta conhas. Y todo llega como texto, de modo quelimiteCreditohay que convertirlo conNumber. ConObject.fromEntriespierdes además los valores repetidos: desectoressolo 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.submitteresundefined. Si usas una versión anterior: leee.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, cambiardefaultValueno actualiza su atributovalue; en 19.3 sí lo hace, igual que en los demás tipos de campo. Si usas una versión anterior: no cambiesdefaultValuecon el campo enfocado; para reiniciarlo con otro valor, cámbiale lakey.
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>
);
}

Figura 4. Tras intentar guardar, cada campo muestra su error, y el foco fue al primero con problemas.
Merece la pena repasar las piezas:
noValidateen el<form>desactiva las burbujas del navegador, porque los mensajes los ponemos nosotros. Sin él, unrequiredimpediría que se ejecutara nuestroenviar.tocadosse marca cononBlur, el evento de perder el foco, de modo que el error aparece cuando el usuario sale del campo.intentadohace visibles todos los errores en cuanto se pulsa Guardar, incluidos los de campos que nunca se tocaron.namedItem(primero)busca el campo por sunamepara enfocarlo.Object.keysrespeta el orden de inserción, y por esovalidarcomprueba los campos en el orden en que aparecen en pantalla. En nuestra prueba, tras pulsar Guardar con todo vacío, el foco va anombre.
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.
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:
placeholderno 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>
</>
);
}

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
useIdpara laskeyde una lista: esas deben salir de tus datos, como explica el módulo 06. Tampoco usesMath.random()para losid: cambiaría en cada render, y en el servidor daría un valor distinto al del navegador.useIdda 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.
useIdgenera_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 enquerySelector, escápalo conCSS.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:

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
cambiarborra 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.finallydevuelveenviandoafalsetanto si ha ido bien como si ha fallado.disabled={enviando}evita el doble envío al pulsar dos veces.catchcubre el caso de que no se pueda conectar.fetchno rechaza la promesa con un 422 ni un 500: solo cuando no hay respuesta. Por eso comprobamosrespuesta.statusyrespuesta.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: usaonSubmitcone.preventDefault(),new FormData(e.currentTarget)y un estadoenviando, 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().
Figura 8. Con action, React asume los pasos que en onSubmit escribes a mano.
⚠️ Importante:
useFormStatusdebe 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, ypendingse queda enfalsedurante toda la Action, sin error ni aviso. Por esoBotonGuardares un componente aparte. Se importa dereact-dom, no dereact.
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 elcheckboxa 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
actiondevuelvavoidoPromise<void>. Si devuelves{ error: 'mal' }, no compila, y aunque compilara nadie recogería ese valor. Quien recoge lo que devuelve una Action esuseActionState.
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 enonResetpara 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:
- Los datos viven en el componente padre, que no se desmonta. Los pasos solo se muestran y se ocultan.
- Cada paso valida solo sus campos, antes de dejarte avanzar.
- Un único envío real, al final, con todos los datos.
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>
);
}

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

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.avanzares elonSubmitpara 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. Anteriorestype="button", así que no envía ni valida. El usuario puede retroceder aunque el paso actual tenga errores.VALIDADORESes 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 esparcirfalseno 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 ytabIndex={-1}permite enfocar un título. En la prueba, tras avanzar, el elemento activo esPaso 2 de 3: Puesto. Pasar una función arefse 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:
enfocarestá 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
presupuestocomo número, onullsi 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 ye.nativeEvent.submitterpara 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 |