Formularios en un componente
En resumen: Un campo controlado en React guarda su valor en el estado (value y onChange), así que el estado es la única fuente para comprobar y enviar el formulario.
Problema
Escrito, pero vacío
El comprador rellena el pedido y pulsa «Hacer el pedido». El servidor responde que el nombre es obligatorio. Y el campo «Nombre» muestra el nombre.
Consola
Escribe un nombre, un correo y una dirección, acepta las condiciones y pulsa «Hacer el pedido». El campo conserva «Maria Ivanova», pero la petición lleva name: ''. El correo y la dirección tienen value y onChange, y el nombre no. El código envía el estado, y el estado no sabe lo que hay escrito en el campo.
La idea
Una verdad: el estado
En la lección sobre el DOM el formulario guardaba el texto él mismo, y el código lo leía al enviar. En un componente hay un segundo lugar: el estado. Si los dos no están conectados, cada uno dice lo suyo, como en el ejemplo.
La solución es que el campo no tenga verdad propia. Muestra el valor del estado y en cada tecla le dice al estado el texto nuevo. Un campo así se llama controlado (en inglés, controlled input).
const [name, setName] = useState('');
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />value={name} es el sentido del estado al campo. onChange es el camino de vuelta: e.target.value es el texto del campo en el momento del evento. En React onChange llega en cada tecla, no solo cuando el campo pierde el foco.
Los demás tipos de campos son iguales, con otra propiedad:
<select value={payment} onChange={(e) => setPayment(e.target.value)}>…</select><input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />Ahora el estado es la única fuente: lo que hay en él está en el campo, y al revés. El botón se puede desactivar con el estado (!agree), y la petición envía exactamente lo que ve el comprador.
Míralo
Mira el círculo
Una letra recorre todo el camino: campo, evento, estado, nuevo dibujado y otra vez el campo. Después, el mismo campo sin ligadura.
function CheckoutForm() { const [name, setName] = useState('');
return ( <form onSubmit={submit}> <label htmlFor="name">Nombre</label> <input id="name" value={name} onChange={(e) => setName(e.target.value)} /> </form> );}Paso a paso, en palabras
- El formulario guarda el nombre en el estado: name = ''. El campo de la página muestra lo que dice el estado, y dice: vacío.
- El comprador pulsa M. El navegador pone la letra en el campo y lanza el evento input.
- onChange recoge el evento. e.target.value es ahora el texto del campo: «M».
- setName("M") cambia el estado. Es el único lugar donde vive el nombre.
- El estado cambió, así que React vuelve a llamar a CheckoutForm. Esta devuelve el campo con value = name: «M».
- El campo recibe su valor del estado. Es la misma letra, pero ahora el estado decide lo que muestra el campo.
- Cada tecla recorre todo el círculo: campo, evento, estado, nuevo dibujado, campo. Una sola fuente de verdad: el estado.
- Ahora el mismo campo sin value ni onChange. El campo funciona, pero nadie lo escucha y nadie le da un valor.
- El comprador escribe «María». Las letras se quedan en el DOM. El estado sigue diciendo: name = ''.
- Al enviar, el código manda el estado: { name: '' }. El servidor responde «El nombre es obligatorio», mientras que el campo muestra «María».
- La regla: cada campo tiene value desde el estado y onChange de vuelta a él. Así el campo y el estado no pueden desajustarse.
El círculo parece un rodeo innecesario: la letra está en el campo también sin él. Pero gracias a él el estado y el campo no pueden diferir.
Pruébalo
Liga los campos
El campo Nombre, el select Pago y la casilla Acepto las condiciones no están ligados al estado. Liga los tres. El botón Hacer el pedido está desactivado hasta que se aceptan las condiciones. La comprobación escribe un nombre, un correo y una dirección, elige Tarjeta en la entrega, marca y desmarca la casilla y después hace el pedido. Espera Pedido n.º 61 recibido. y una petición con el nombre escrito y payment: 'card'.
Consola
Solución
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Pedido</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [problem, setProblem] = useState('');
async function submit(e) {
e.preventDefault();
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
if (res.ok) setOrderId(data.id);
else setProblem(Object.values(data.errors).join(' '));
}
if (orderId) return <p role="status">Pedido n.º {orderId} recibido.</p>;
return (
<form onSubmit={submit} noValidate>
<p>En el carrito: Pan blanco × 2, Leche fresca × 1</p>
<label htmlFor="name">Nombre</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="email">Correo electrónico</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label htmlFor="address">Dirección</label>
<input id="address" value={address} onChange={(e) => setAddress(e.target.value)} />
<label htmlFor="payment">Pago</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Contra reembolso</option>
<option value="card">Tarjeta en la entrega</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Acepto las condiciones
</label>
{problem && <p role="alert">{problem}</p>}
<button type="submit" disabled={!agree}>
Hacer el pedido
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
El nombre recibe value={name} y onChange={(e) => setName(e.target.value)}. El select recibe value={payment} y un onChange con setPayment. La casilla recibe checked={agree} y onChange={(e) => setAgree(e.target.checked)}.
La comprobación detecta value={name} sin onChange: el campo pasa a ser de solo lectura, y la consola dice You provided a `value` prop to a form field without an `onChange` handler. También detecta setAgree(e.target.value): el valor de la casilla es «on» en cada clic, así que una vez marcada, nunca sale del estado.
Pruébalo
Rellena el hueco
¿Qué va en el hueco para que la casilla quede ligada?
const [agree, setAgree] = useState(false);
<input
type="checkbox"
checked={agree}
onChange={(e) => setAgree(e.target.)}
/>checked: una casilla dice si está marcada con e.target.checked, true o false. e.target.value es el texto que se envía con el formulario, y no cambia con un clic.Pruébalo
Encuentra el error
Elige la línea con el error.
value={name} sin onChange. El campo muestra name, pero nadie cambia name, así que React lo devuelve a vacío después de cada tecla. Necesita también onChange={(e) => setName(e.target.value)}.La idea
Una comprobación junto al campo
El servidor comprueba cada pedido. Pero el comprador no debería esperar una respuesta para enterarse de que olvidó la dirección. Así que el formulario también comprueba por sí mismo antes de enviar.
- Cuándo. Al pulsar «Hacer el pedido». Si hay errores, no sale ninguna petición.
- Qué. Una función
check()devuelve un objeto con los errores por campo:{ name: 'Escribe tu nombre', email: … }. Un objeto vacío significa «todo bien». Se guarda en el estadoerrors. - Dónde. Junto al campo, no en una lista común arriba. El comprador ve qué campo debe corregir.
- Para todos. Un lector de pantalla no ve que el texto es rojo. Por eso el campo recibe
aria-invalid="true"(el lector dice «no válido»), y el mensaje recibe unidal que el campo apunta conaria-describedby. Cuando el foco llega al campo, el lector lee también el mensaje.
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined}/>{errors.email && <p id="email-error" className="error">{errors.email}</p>}El formulario tiene novalidate (en JSX noValidate). Sin él, el navegador detiene el envío con sus propios globos antes de que llegue tu código, y las dos comprobaciones se estorban.
El servidor comprueba otra vez. La comprobación del navegador es una comodidad para el comprador. No protege la tienda: el código de la página está en manos del comprador, y una petición puede llegar también sin él. Por qué es así lo explica la lección «Entrada no confiable».
Pruébalo
Comprueba antes de enviar
Añade una comprobación antes de enviar. Un nombre vacío da Escribe tu nombre, un correo sin @ da El correo debe contener @, una dirección vacía da Escribe una dirección de entrega. Cada mensaje está junto a su campo, en un elemento con el id name-error, email-error y address-error, y el campo tiene aria-invalid=true y aria-describedby apuntando a él. Con errores no sale ninguna petición. La comprobación acepta las condiciones, hace el pedido con el formulario vacío, después con un correo sin @ y al final con todo correcto.
Consola
Solución
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Pedido</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [problem, setProblem] = useState('');
const [errors, setErrors] = useState({});
function check() {
const found = {};
if (!name.trim()) found.name = 'Escribe tu nombre';
if (!email.includes('@')) found.email = 'El correo debe contener @';
if (!address.trim()) found.address = 'Escribe una dirección de entrega';
return found;
}
async function submit(e) {
e.preventDefault();
const found = check();
setErrors(found);
if (Object.keys(found).length > 0) return;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
if (res.ok) setOrderId(data.id);
else setProblem(Object.values(data.errors).join(' '));
}
if (orderId) return <p role="status">Pedido n.º {orderId} recibido.</p>;
return (
<form onSubmit={submit} noValidate>
<p>En el carrito: Pan blanco × 2, Leche fresca × 1</p>
<label htmlFor="name">Nombre</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && <p id="name-error" className="error">{errors.name}</p>}
<label htmlFor="email">Correo electrónico</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" className="error">{errors.email}</p>}
<label htmlFor="address">Dirección</label>
<input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
aria-invalid={!!errors.address}
aria-describedby={errors.address ? 'address-error' : undefined}
/>
{errors.address && <p id="address-error" className="error">{errors.address}</p>}
<label htmlFor="payment">Pago</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Contra reembolso</option>
<option value="card">Tarjeta en la entrega</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Acepto las condiciones
</label>
{problem && <p role="alert">{problem}</p>}
<button type="submit" disabled={!agree}>
Hacer el pedido
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Añade const [errors, setErrors] = useState({}) y una función check() que devuelva un objeto con los errores. En submit primero const found = check(); setErrors(found);, y si found tiene claves, un return antes de fetch. Junto a cada campo: aria-invalid={!!errors.name}, aria-describedby={errors.name ? 'name-error' : undefined} y {errors.name && <p id="name-error">…</p>}.
La comprobación detecta un mensaje sin aria-invalid y aria-describedby: en pantalla se ve igual, pero para un lector de pantalla el campo está bien. También detecta mensajes tras los cuales la petición sale de todos modos: entonces, un momento después, aparece también la respuesta del servidor.
La idea
Enviar y la respuesta del servidor
La petición tarda, y el comprador no sabe si su clic llegó. Si vuelve a hacer clic, sale un segundo pedido. Por eso el estado tiene también sending:
- Antes de
fetch,sendingpasa atrue. El botón estádisabled, y a su lado dice «Enviando…». - Después de la respuesta,
sendingpasa afalse, sea lo que sea lo que haya vuelto. - 201: el cuerpo es
{ id }. El formulario se sustituye por «Pedido n.º 61 recibido.» - 400: el cuerpo es
{ errors: { name, email, address, items } }, un mensaje por campo. Van al mismoerrors, así que aparecen en los mismos lugares junto a los campos.itemsno tiene campo: aparece junto a la línea con el carrito.
setSending(true);const res = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, address, payment, items: ITEMS }),});const data = await res.json();setSending(false);if (res.ok) setOrderId(data.id);else setErrors(data.errors);La comprobación del navegador es más simple que la del servidor: solo busca «@». El correo maria@shop la pasa, y el servidor lo devuelve con «El correo electrónico no es válido». Así que el formulario también debe saber mostrar un error que no conocía.
Más precisamente, para curiosos
Campos no controlados. Un campo sin value guarda el texto él mismo. El valor se toma al enviar: de FormData o con un ref. defaultValue solo da el texto inicial. Es menos código, pero el estado no sabe lo que escribe el comprador hasta que envía. Así que no puedes desactivar el botón según el campo ni comprobar mientras escribe.
De no controlado a controlado. Si value es undefined al principio (useState() sin valor inicial) y más tarde pasa a ser texto, React dice en la consola A component is changing an uncontrolled input to be controlled. El valor inicial de un campo de texto es '', no undefined.
Formularios con action en React 19. <form action={fn}> llama a fn con los FormData del formulario y después vacía los campos no controlados. También hay hooks para ello (useActionState, useFormStatus). Es otro estilo, más cercano al formulario de la lección de HTML. Los campos controlados siguen siendo el camino más directo cuando la interfaz debe reaccionar mientras el comprador escribe.
Un objeto en vez de cinco estados. También puedes tener const [form, setForm] = useState({ name: '', email: '', … }) con una sola función onChange que lea e.target.name. Entonces el objeto nuevo es una copia: setForm({ ...form, [e.target.name]: e.target.value }).
Cuándo comprobar. Al enviar es lo más simple. Una comprobación en cada tecla muestra errores antes de que el comprador termine («el correo no es válido» tras la primera letra). La respuesta habitual: la primera comprobación al enviar o al salir del campo (blur), y después en cada tecla, para que el error desaparezca en cuanto se corrige.
Foco en un error. Tras un envío fallido ayuda mover el foco al primer campo con error. Así el teclado y el lector de pantalla llegan directo al problema.
La comprobación no ve el código. Las tareas de aquí comprueban el comportamiento: qué se muestra, qué está desactivado, qué se envía. No ven si la comprobación está en una función aparte ni cómo se llaman las variables.
Ejercicio
El pedido, hasta el final
Ahora el formulario comprueba en el navegador, pero no muestra que está enviando y mete los errores del servidor en un solo párrafo. Conviértelo en un pedido de verdad: mientras la petición se ejecuta, el botón Hacer el pedido está desactivado y hay un texto Enviando… (con role=status). Ante una respuesta 400, los errores de data.errors van junto a los campos, en los mismos lugares que los tuyos (email-error y aria-invalid en el correo), y el error de items junto a la línea con el carrito. La comprobación escribe el correo maria@shop: pasa tu comprobación, pero el servidor lo devuelve con El correo electrónico no es válido. Después añade .bg y el pedido sale bien: Pedido n.º 61 recibido.
Consola
Solución
App.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Pedido</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
function check() {
const found = {};
if (!name.trim()) found.name = 'Escribe tu nombre';
if (!email.includes('@')) found.email = 'El correo debe contener @';
if (!address.trim()) found.address = 'Escribe una dirección de entrega';
return found;
}
async function submit(e) {
e.preventDefault();
const found = check();
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
setSending(false);
if (res.ok) setOrderId(data.id);
else setErrors(data.errors);
}
if (orderId) return <p role="status">Pedido n.º {orderId} recibido.</p>;
return (
<form onSubmit={submit} noValidate>
<p>En el carrito: Pan blanco × 2, Leche fresca × 1</p>
{errors.items && <p role="alert">{errors.items}</p>}
<label htmlFor="name">Nombre</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && <p id="name-error" className="error">{errors.name}</p>}
<label htmlFor="email">Correo electrónico</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" className="error">{errors.email}</p>}
<label htmlFor="address">Dirección</label>
<input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
aria-invalid={!!errors.address}
aria-describedby={errors.address ? 'address-error' : undefined}
/>
{errors.address && <p id="address-error" className="error">{errors.address}</p>}
<label htmlFor="payment">Pago</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Contra reembolso</option>
<option value="card">Tarjeta en la entrega</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Acepto las condiciones
</label>
{sending && <p role="status">Enviando…</p>}
<button type="submit" disabled={!agree || sending}>
Hacer el pedido
</button>
</form>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Añade const [sending, setSending] = useState(false). Alrededor de fetch: setSending(true) antes y setSending(false) tras la respuesta. El botón es disabled={!agree || sending}, y {sending && <p role="status">Enviando…</p>} está encima. Ante un error, en lugar del párrafo común, setErrors(data.errors): los mensajes del servidor van al mismo errors y aparecen junto a los campos. El párrafo problem ya no hace falta.
La comprobación detecta un botón que sigue activado mientras dice «Enviando…», y un botón que nunca se activa tras la respuesta (un false olvidado).
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: El primer componente · Condiciones: vacío, error, cargando · Estado · Datos de una API en un componente
- Continúa en: Estado compartido · El escaparate de la tienda
- Ejercicios sobre el tema: Efectos, datos y formularios