El catálogo de CicloUrbano ya se pinta desde los datos, reacciona a los clics y se adapta al estado de cada bicicleta. Pero sigue siendo una vía de un solo sentido: la aplicación muestra información y no recoge ninguna. Para crear una reserva hace falta elegir una bicicleta, indicar cuándo empieza, cuántas horas dura y aceptar las condiciones, y eso significa formularios. En React los formularios tienen una particularidad que descoloca al principio: por defecto, un <input> guarda su propio valor dentro del DOM, al margen de React, lo cual choca de frente con la idea de que la interfaz es una función del estado. La solución se llama componente controlado, y en esta lección vas a dominarla campo por campo —texto, área de texto, desplegable, casillas, radios, números y fechas—, aprenderás a manejar un formulario entero con un solo estado y un solo manejador, y construirás el FormularioReserva de CicloUrbano de principio a fin.
Contenido
- El conflicto: dos fuentes de verdad
- Qué es un componente controlado
value+onChangepaso a paso- Un
valuesinonChange: el campo congelado - Campos de texto y
textarea - El desplegable
select, simple y múltiple - Casillas de verificación con
checked - Grupos de botones de radio
numberydate: la conversión de tipos- Un solo estado para todo el formulario
- El envío:
onSubmitypreventDefault - CicloUrbano:
FormularioReserva
- El conflicto: dos fuentes de verdad
En HTML, los campos de formulario son elementos con memoria propia. Cuando escribes en un <input>, el navegador guarda ese texto dentro del nodo del DOM y nadie más se entera:
<input type="text" id="modelo" value="Urbana" />
<!-- Al escribir, el valor cambia dentro del DOM. Para leerlo: -->
<script>
const texto = document.getElementById('modelo').value;
</script>Ese comportamiento es cómodo en una página estática y problemático en React, porque crea dos sitios donde vive la misma información:
| Fuente | Qué sabe | Problema |
|---|---|---|
| El DOM | Lo que ha escrito la persona usuaria | React no se entera del cambio |
| El estado de React | Lo que React cree que hay | Puede quedar desfasado del DOM |
Con dos fuentes de verdad aparecen preguntas sin respuesta clara: ¿cómo habilitas el botón «Enviar» solo cuando el campo tiene contenido, si React no sabe qué hay dentro? ¿Cómo pones el texto en mayúsculas mientras se escribe? ¿Cómo vacías el formulario tras enviarlo?
La respuesta de React es tajante: que solo haya una fuente de verdad, y que sea el estado.
- Qué es un componente controlado
Un componente controlado es un campo de formulario cuyo valor lo dicta el estado de React. El campo no decide nada: solo muestra lo que el estado le dice y avisa de los intentos de cambio.
import { useState } from 'react';
function BuscadorModelo() {
const [texto, setTexto] = useState('');
return (
<input
type="text"
value={texto} // el estado manda
onChange={(evento) => setTexto(evento.target.value)} // el campo avisa
/>
);
}El ciclo completo, que conviene entender de verdad porque es contraintuitivo la primera vez:
flowchart TD
A["La persona pulsa una tecla"] --> B["Se dispara onChange"]
B --> C["El manejador lee evento.target.value"]
C --> D["setTexto(nuevoValor)<br/>actualiza el estado"]
D --> E["React vuelve a renderizar"]
E --> F["El input recibe value = nuevo estado"]
F --> G["Se ve la letra en pantalla"]
Lo sorprendente es el paso final: la letra no aparece porque la escribas, sino porque el estado ha cambiado. Si el manejador no actualizara el estado, la tecla no dejaría rastro por mucho que la pulses. El campo es una ventana al estado, no un almacén.
Esto es exactamente interfaz = f(estado) aplicado a los formularios, y de ahí salen todas las ventajas:
| Ventaja | Ejemplo concreto |
|---|---|
| Un solo sitio donde mirar | El valor está en el estado; no hay que consultar el DOM |
| Validación en tiempo real | Se puede comprobar en cada tecla (lección 03-05) |
| Transformar mientras se escribe | Poner en mayúsculas, limitar caracteres, formatear |
| Interfaz reactiva | Deshabilitar el botón, mostrar un contador de caracteres, previsualizar |
| Reiniciar es trivial | setDatos(valoresIniciales) y el formulario se vacía solo |
| Fácil de probar | Cambiar el estado equivale a rellenar el formulario |
Existe la alternativa —dejar que el DOM guarde el valor y leerlo solo al final—, y se llama componente no controlado. Tiene sus casos de uso legítimos y se trata en la próxima lección, Validación de Formularios y Componentes No Controlados. En el 90 % de los formularios de una aplicación React, la respuesta correcta es «controlado».
value + onChange paso a paso
value + onChange paso a pasoLos dos ingredientes son inseparables. Analicémoslos por separado.
value: el estado manda
Le dice al campo: «muestres lo que muestres, tu contenido es este». En cada render React comprueba el valor del DOM y, si no coincide con la prop, lo corrige.
onChange: el campo avisa
Tres detalles importantes de esta línea:
evento.targetes el nodo del DOM que ha originado el evento: el<input>. Como aprendiste en 03-01, es distinto decurrentTarget, aunque en un campo suelto coincidan..valuees siempre una cadena de texto, incluso en un<input type="number">. Volveremos sobre esto en el apartado 9.onChangeen React se dispara en cada pulsación de tecla, a diferencia del eventochangenativo del DOM, que solo salta al perder el foco. Esa diferencia es la que hace posible todo este patrón.
El manejador con nombre
Para cualquier cosa que no sea trivial, extrae el manejador siguiendo la convención de 03-01:
import { useState } from 'react';
function BuscadorModelo({ alBuscar }) {
const [texto, setTexto] = useState('');
function manejarCambioTexto(evento) {
const valor = evento.target.value;
setTexto(valor);
if (alBuscar) {
alBuscar(valor); // avisamos al padre en cada pulsación
}
}
return (
<label>
Buscar modelo:{' '}
<input
type="text"
value={texto}
onChange={manejarCambioTexto}
placeholder="Urbana, Eléctrica…"
/>
</label>
);
}
export default BuscadorModelo;Fíjate en que aquí sí pasamos la referencia (onChange={manejarCambioTexto}), sin flecha, porque no hacen falta argumentos extra: React ya pasa el evento como primer parámetro.
Transformar mientras se escribe
Como el valor pasa por tu código antes de volver a la pantalla, puedes modificarlo:
function manejarCambioCodigo(evento) {
// Solo mayúsculas y como mucho 8 caracteres
const valor = evento.target.value.toUpperCase().slice(0, 8);
setCodigo(valor);
}Esto es imposible con un campo no controlado sin manipular el DOM a mano. Es una de las razones de peso para preferir los controlados.
- Un
value sin onChange: el campo congelado
value sin onChange: el campo congeladoEste es el error número uno con formularios en React, y el aviso que React muestra es de los pocos que la gente lee entero porque el síntoma es alarmante.
Síntoma: el campo no admite escritura. Pulsas teclas y no pasa nada, como si estuviera bloqueado.
Causa: el value está atado al estado, y sin onChange el estado nunca cambia. En cada intento de escritura React reimpone el valor anterior.
Aviso en consola:
Warning: You provided a
valueprop to a form field without anonChangehandler. This will render a read-only field. If the field should be mutable usedefaultValue. Otherwise, set eitheronChangeorreadOnly.
El propio mensaje enumera las tres salidas válidas:
| Intención | Solución |
|---|---|
| El campo debe ser editable | Añadir onChange que actualice el estado |
| El campo debe ser de solo lectura de forma intencionada | Añadir readOnly junto al value |
| El campo debe tener un valor inicial pero gestionarse por el DOM | Usar defaultValue en lugar de value (campo no controlado, lección 03-05) |
{/* Solo lectura intencionada: el aviso desaparece */}
<input type="text" value={bicicleta.id} readOnly />Hay un segundo aviso, hermano del anterior, que aparece cuando el estado inicial es undefined o null:
Warning: A component is changing an uncontrolled input to be controlled.
Ocurre así: en el primer render value={undefined} hace que React trate el campo como no controlado; en cuanto el estado recibe una cadena, pasa a ser controlado, y React avisa del cambio de régimen. La solución es siempre la misma: inicializa el estado con una cadena vacía, nunca con undefined ni con null.
const [texto, setTexto] = useState(''); // ✔ correcto
const [texto, setTexto] = useState(); // ✘ provoca el aviso
const [texto, setTexto] = useState(null); // ✘ provoca el aviso
- Campos de texto y
textarea
textareaLos campos de una línea funcionan todos igual, cambie el type que cambie:
<input type="text" value={nombre} onChange={manejarCambio} />
<input type="email" value={email} onChange={manejarCambio} />
<input type="password" value={clave} onChange={manejarCambio} />
<input type="search" value={busqueda} onChange={manejarCambio} />
<input type="tel" value={telefono} onChange={manejarCambio} />El type cambia el teclado en móvil y la validación nativa del navegador, pero el patrón React es idéntico.
El <textarea> sí tiene una diferencia. En HTML, su contenido va entre las etiquetas:
En React, no: se trata como un campo más y el valor va en la prop value.
{/* ✔ React: el contenido va en value */}
<textarea rows={4} value={comentario} onChange={manejarCambioComentario} />
{/* ✘ No hagas esto: React avisa de que use la prop value */}
<textarea rows={4} onChange={manejarCambioComentario}>{comentario}</textarea>La razón es la coherencia: así todos los campos se leen y se escriben igual, sin excepciones que recordar.
Un ejemplo completo con contador de caracteres, que muestra lo cómodo que resulta tener el valor en el estado:
import { useState } from 'react';
const MAXIMO = 200;
function NotaOperario() {
const [nota, setNota] = useState('');
const restantes = MAXIMO - nota.length; // valor derivado, no estado
return (
<div>
<label htmlFor="nota-operario">Nota del operario</label>
<textarea
id="nota-operario"
rows={4}
maxLength={MAXIMO}
value={nota}
onChange={(evento) => setNota(evento.target.value)}
/>
<p className={restantes < 20 ? 'aviso' : ''}>
Quedan {restantes} caracteres.
</p>
</div>
);
}
export default NotaOperario;
- El desplegable
select, simple y múltiple
select, simple y múltipleAquí React se aparta claramente del HTML. En HTML, la opción seleccionada se marca con el atributo selected en el <option>:
<!-- HTML clásico -->
<select>
<option value="urbana">Urbana</option>
<option value="electrica" selected>Eléctrica</option>
</select>En React, el value va en el <select> y ningún <option> lleva selected:
function SelectorEstacion({ estaciones, estacionId, alCambiar }) {
return (
<label>
Estación de recogida:{' '}
<select value={estacionId} onChange={alCambiar}>
<option value="">— Elige una estación —</option>
{estaciones.map((estacion) => (
<option key={estacion.id} value={estacion.id}>
{estacion.nombre} ({estacion.barrio})
</option>
))}
</select>
</label>
);
}Puntos clave:
- El
valuedelselectdebe coincidir con elvaluede alguna<option>. Si no coincide con ninguna, el navegador muestra la primera y aparece una desincronización difícil de ver. - La opción vacía
<option value="">es el truco habitual para representar «nada elegido todavía». Combina bien con la validación de la próxima lección. - Las opciones generadas con
mapnecesitankey, como cualquier lista (lección 03-03). evento.target.valuesiempre es una cadena. Si tus identificadores fueran numéricos, harían falta conversiones; con losest-01de CicloUrbano no hay problema.
El select múltiple
Con multiple, el valor deja de ser una cadena y pasa a ser un array, y hay que leer las opciones seleccionadas del DOM:
import { useState } from 'react';
const TIPOS = ['urbana', 'electrica', 'carga'];
function FiltroTiposMultiple() {
const [tipos, setTipos] = useState([]); // array, no cadena
function manejarCambio(evento) {
// evento.target.selectedOptions es una colección tipo array, no un array real
const elegidos = Array.from(evento.target.selectedOptions, (opcion) => opcion.value);
setTipos(elegidos);
}
return (
<>
<select multiple value={tipos} onChange={manejarCambio} size={3}>
{TIPOS.map((tipo) => (
<option key={tipo} value={tipo}>
{tipo}
</option>
))}
</select>
<p>Seleccionados: {tipos.length > 0 ? tipos.join(', ') : 'ninguno'}</p>
</>
);
}
export default FiltroTiposMultiple;Array.from(coleccion, funcion) convierte la colección del DOM en un array real aplicando de paso la transformación, en una sola pasada. Y recuerda el tipos.length > 0 en lugar de tipos.length &&: la trampa del 0 de la lección 03-02 sigue vigente.
- Casillas de verificación con
checked
checkedUna casilla no tiene «texto»: tiene un estado de marcado. Por eso usa dos props distintas:
| Campo | Prop de valor | Propiedad del evento | Tipo del estado |
|---|---|---|---|
text, textarea, select |
value |
evento.target.value |
cadena |
checkbox |
checked |
evento.target.checked |
booleano |
radio |
checked |
evento.target.checked |
booleano por opción |
import { useState } from 'react';
function AceptacionCondiciones() {
const [aceptado, setAceptado] = useState(false);
return (
<label>
<input
type="checkbox"
checked={aceptado}
onChange={(evento) => setAceptado(evento.target.checked)}
/>{' '}
Acepto las condiciones de uso de CicloUrbano
</label>
);
}
export default AceptacionCondiciones;El error clásico es escribir setAceptado(evento.target.value): en una casilla, value vale "on" por defecto, que es una cadena siempre truthy. La casilla se marcaría y no podría desmarcarse nunca.
Un grupo de casillas independientes
Cuando hay varias casillas relacionadas, lo natural es guardar un objeto o un array:
import { useState } from 'react';
const ESTADOS = ['disponible', 'alquilada', 'mantenimiento'];
function FiltroEstados() {
const [marcados, setMarcados] = useState({
disponible: true,
alquilada: false,
mantenimiento: false
});
function manejarCambio(evento) {
const { name, checked } = evento.target;
// Copia del objeto anterior + sobrescritura de una sola clave
setMarcados((anterior) => ({ ...anterior, [name]: checked }));
}
return (
<fieldset>
<legend>Mostrar bicicletas en estado…</legend>
{ESTADOS.map((estado) => (
<label key={estado}>
<input
type="checkbox"
name={estado}
checked={marcados[estado]}
onChange={manejarCambio}
/>{' '}
{estado}
</label>
))}
</fieldset>
);
}
export default FiltroEstados;Aquí aparece por primera vez la pieza que vertebra el apartado 10: [name]: checked, un nombre de propiedad calculado. Lo detallamos allí.
- Grupos de botones de radio
Los radios son un caso especial: varios campos comparten un único valor. Lo que los agrupa es el atributo name, que debe ser idéntico en todos, y cada uno declara qué valor representa.
import { useState } from 'react';
const OPCIONES = [
{ valor: 'urbana', etiqueta: 'Urbana' },
{ valor: 'electrica', etiqueta: 'Eléctrica' },
{ valor: 'carga', etiqueta: 'De carga' }
];
function SelectorTipoRadio() {
const [tipo, setTipo] = useState('urbana');
return (
<fieldset>
<legend>Tipo de bicicleta</legend>
{OPCIONES.map((opcion) => (
<label key={opcion.valor}>
<input
type="radio"
name="tipoBicicleta" // el MISMO name en todos
value={opcion.valor} // lo que representa este radio
checked={tipo === opcion.valor} // ¿es este el elegido?
onChange={(evento) => setTipo(evento.target.value)}
/>{' '}
{opcion.etiqueta}
</label>
))}
</fieldset>
);
}
export default SelectorTipoRadio;La línea que hay que entender es checked={tipo === opcion.valor}. Cada radio se pregunta: «¿el valor del estado soy yo?». Solo uno responde que sí, y eso garantiza por construcción que no puedan estar marcados dos a la vez.
En onChange se lee evento.target.value —no checked—, porque lo que queremos guardar es cuál se ha elegido, no si está marcado.
Un detalle de accesibilidad que se desarrollará en 03-06: agrupar los radios en un <fieldset> con <legend> no es decorativo. Es lo que permite a un lector de pantalla anunciar «Tipo de bicicleta, grupo, opción 2 de 3».
number y date: la conversión de tipos
number y date: la conversión de tiposAquí está la trampa silenciosa de los formularios en React: evento.target.value es SIEMPRE una cadena, sin importar el type del campo.
<input type="number" value={horas} onChange={(e) => setHoras(e.target.value)} />
// Si escribes 3, el estado guarda "3" (cadena), no 3 (número)Las consecuencias aparecen en cuanto haces cuentas:
const horas = '3'; // lo que hay realmente en el estado
horas * 4.0 // 12 -> la multiplicación convierte, esto funciona
horas + 1 // "31" -> ✘ la suma concatena cadenas
horas > 24 // false -> compara "3" con 24, convierte, funciona por suerteLa solución es convertir en el manejador, para que el estado guarde siempre el tipo correcto:
function manejarCambioHoras(evento) {
const valor = evento.target.value;
// Campo vacío: guardamos cadena vacía para que el input siga siendo controlado
if (valor === '') {
setHoras('');
return;
}
setHoras(Number(valor));
}¿Por qué ese caso especial del vacío? Porque Number('') es 0, y si convirtiéramos sin más, borrar el campo escribiría un 0 que no se puede borrar. Guardar la cadena vacía mantiene el campo controlado y permite vaciarlo.
| Tipo de campo | Qué devuelve .value |
Conversión recomendada |
|---|---|---|
text, textarea, select |
cadena | Ninguna |
checkbox |
usar .checked |
Ninguna (ya es booleano) |
number |
cadena, o '' si está vacío o es inválido |
Number(valor), tratando '' aparte |
range |
cadena | Number(valor) |
date |
cadena "2026-05-04" |
Ninguna para guardar; new Date(valor) para calcular |
datetime-local |
cadena "2026-05-04T09:00" |
Ninguna: coincide con el formato de dominio.js |
time |
cadena "09:00" |
Ninguna |
file |
.files, no .value |
Siempre no controlado (lección 03-05) |
Sobre las fechas hay una buena noticia para CicloUrbano: el formato que produce <input type="datetime-local"> es exactamente "2026-05-04T09:00", el mismo que usa el campo fechaInicio del dominio.js. No hace falta ninguna conversión para guardarlo; solo la necesitarás para compararlo, y eso llega en la próxima lección.
Los campos number y date admiten además atributos que el navegador respeta: min, max y step. Son una ayuda, pero no una garantía: se pueden esquivar. La validación de verdad es tema de 03-05.
- Un solo estado para todo el formulario
Con cuatro campos, tener cuatro useState y cuatro manejadores empieza a ser ruidoso:
// Funciona, pero no escala
const [bicicletaId, setBicicletaId] = useState('');
const [fechaInicio, setFechaInicio] = useState('');
const [horas, setHoras] = useState(1);
const [condiciones, setCondiciones] = useState(false);La alternativa idiomática es un objeto de estado y un manejador genérico:
const [datos, setDatos] = useState({
bicicletaId: '',
fechaInicio: '',
horas: 1,
condiciones: false
});
function manejarCambio(evento) {
const { name, type, value, checked } = evento.target;
const valorFinal = type === 'checkbox' ? checked : value;
setDatos((anterior) => ({ ...anterior, [name]: valorFinal }));
}Analicemos las dos líneas clave.
El nombre de propiedad calculado
Los corchetes alrededor de name son sintaxis de JavaScript (ES6) llamada nombre de propiedad calculado. Significa: «usa el contenido de la variable name como nombre de la propiedad».
const name = 'horas';
{ [name]: 3 } // -> { horas: 3 } ✔ el nombre sale de la variable
{ name: 3 } // -> { name: 3 } ✘ el nombre es literalmente "name"Combinado con el spread ...anterior, la expresión completa significa: «copia todo lo que había y sustituye solo la propiedad cuyo nombre coincide con el name del campo». Es la actualización inmutable de objetos que aprendiste en 02-04, aplicada a formularios.
El atributo name
Para que esto funcione, cada campo debe llevar un name que coincida con su clave en el objeto de estado:
<input name="fechaInicio" value={datos.fechaInicio} onChange={manejarCambio} />
<input name="horas" type="number" value={datos.horas} onChange={manejarCambio} />
<input name="condiciones" type="checkbox" checked={datos.condiciones} onChange={manejarCambio} />Si el name no coincide con ninguna clave, el manejador añade una clave nueva al objeto en silencio, y el campo que esperabas actualizar no cambia. Es un fallo tan común como difícil de ver: revisa siempre los name cuando un campo no responda.
La forma funcional del actualizador
Se usa la forma funcional —setDatos(fn) en lugar de setDatos(objeto)— por lo que aprendiste en 02-04: garantiza partir del valor más reciente aunque haya varias actualizaciones seguidas. En un formulario con campos que se afectan entre sí, esa garantía evita perder cambios.
Fíjate también en los paréntesis que envuelven el objeto: (anterior) => ({ … }). Sin ellos, JavaScript interpretaría las llaves como el cuerpo de la función y devolvería undefined.
Un estado o varios: cómo decidir
| Situación | Recomendación |
|---|---|
| 1 o 2 campos independientes | Un useState por campo, más legible |
| 3 o más campos que se envían juntos | Un objeto y un manejador genérico |
| Campos que se validan y reinician a la vez | Un objeto |
| Un campo con lógica muy particular | Su propio useState, aunque el resto vaya en objeto |
| Formularios muy grandes con lógica compleja | useReducer, que verás en Hook useReducer |
- El envío:
onSubmit y preventDefault
onSubmit y preventDefaultEl manejador va en el <form>, no en el botón
{/* ✔ CORRECTO */}
<form onSubmit={manejarEnvio}>
<button type="submit">Reservar</button>
</form>
{/* ✘ INCOMPLETO: solo funciona con clic de ratón */}
<form>
<button type="button" onClick={manejarEnvio}>Reservar</button>
</form>La diferencia no es de estilo, es funcional. Un <form> con onSubmit se envía de tres maneras distintas:
- Pulsando el botón
type="submit". - Pulsando
Enteren cualquier campo de texto del formulario. - Mediante tecnologías de asistencia que activan el envío del formulario.
Con el manejador en el botón solo funciona la primera. La segunda es la que usa muchísima gente sin pensarlo, y su ausencia se percibe como que la aplicación está rota.
preventDefault es obligatorio
function manejarEnvio(evento) {
evento.preventDefault(); // sin esto, la página se recarga
// … procesar los datos
}El comportamiento nativo de un formulario es enviar los datos al servidor y recargar la página. En una aplicación React eso destruye todo el estado y reinicia la aplicación: el síntoma es un parpadeo y un formulario vacío, como si nada hubiera pasado. evento.preventDefault(), que ya conoces de la lección 03-01, lo evita.
El flujo completo del envío
flowchart TD
A["submit del formulario<br/>(botón o tecla Enter)"] --> B["manejarEnvio(evento)"]
B --> C["evento.preventDefault()"]
C --> D["Construir el objeto con los datos del estado"]
D --> E["Avisar al padre con una prop de función"]
E --> F["Reiniciar el estado del formulario"]
Reiniciar el formulario tras enviar
Como el estado es la única fuente de verdad, vaciar el formulario es asignar los valores iniciales. El patrón limpio es guardar esos valores en una constante:
const DATOS_INICIALES = {
bicicletaId: '',
fechaInicio: '',
horas: 1,
condiciones: false
};
function FormularioEjemplo() {
const [datos, setDatos] = useState(DATOS_INICIALES);
function manejarEnvio(evento) {
evento.preventDefault();
// … procesar
setDatos(DATOS_INICIALES); // el formulario se vacía solo
}
// …
}Dos advertencias:
- La constante va fuera del componente. Dentro se recrearía en cada render sin necesidad.
DATOS_INICIALESno se debe mutar nunca. ComosetDatossiempre crea objetos nuevos con spread, el objeto original permanece intacto y se puede reutilizar tantas veces como haga falta.
Existe también evento.target.reset(), el método nativo del DOM, pero no lo uses en un formulario controlado: vaciaría el DOM sin tocar el estado, y React volvería a poner los valores anteriores en el siguiente render. Con campos controlados, se reinicia el estado.
- CicloUrbano:
FormularioReserva
FormularioReservaEs el momento de juntarlo todo. El formulario debe permitir elegir una bicicleta disponible, indicar cuándo empieza la reserva, cuántas horas dura y aceptar las condiciones; al enviarlo, construye un objeto Reserva con la forma del dominio.js y se lo entrega al padre.
// src/componentes/FormularioReserva.jsx
import { useState } from 'react';
import estilos from './FormularioReserva.module.css';
// Constante fuera del componente: no se recrea en cada render
const DATOS_INICIALES = {
bicicletaId: '',
fechaInicio: '',
horas: 2,
condiciones: false
};
/**
* Formulario de creación de reservas de CicloUrbano.
* Props:
* - bicicletas (array, opcional, por defecto []): catálogo completo
* - usuarioId (cadena, opcional, por defecto 'usr-01'): quién reserva
* - alCrearReserva (función, opcional): recibe el objeto Reserva construido
*
* Todos los campos son CONTROLADOS: el estado `datos` es la única fuente de verdad.
* La validación llega en la lección 03-05.
*/
function FormularioReserva({ bicicletas = [], usuarioId = 'usr-01', alCrearReserva }) {
const [datos, setDatos] = useState(DATOS_INICIALES);
// Valores derivados: se recalculan en cada render
const disponibles = bicicletas.filter((bicicleta) => bicicleta.estado === 'disponible');
const elegida = bicicletas.find((bicicleta) => bicicleta.id === datos.bicicletaId);
const total = elegida ? elegida.precioHora * Number(datos.horas || 0) : 0;
const totalFormateado = total.toFixed(2).replace('.', ',');
function manejarCambio(evento) {
const { name, type, value, checked } = evento.target;
// Cada tipo de campo lee su valor de un sitio distinto
let valorFinal = value;
if (type === 'checkbox') {
valorFinal = checked;
} else if (type === 'number') {
valorFinal = value === '' ? '' : Number(value);
}
setDatos((anterior) => ({ ...anterior, [name]: valorFinal }));
}
function manejarEnvio(evento) {
evento.preventDefault(); // sin esto, la página se recargaría
const reserva = {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId: datos.bicicletaId,
usuario: usuarioId,
fechaInicio: datos.fechaInicio,
horas: Number(datos.horas),
estado: 'activa'
};
if (alCrearReserva) {
alCrearReserva(reserva);
}
setDatos(DATOS_INICIALES); // el formulario se vacía
}
return (
<form className={estilos.formulario} onSubmit={manejarEnvio}>
<h2>Nueva reserva</h2>
<div className={estilos.campo}>
<label htmlFor="bicicletaId">Bicicleta</label>
<select
id="bicicletaId"
name="bicicletaId"
value={datos.bicicletaId}
onChange={manejarCambio}
>
<option value="">— Elige una bicicleta —</option>
{disponibles.map((bicicleta) => (
<option key={bicicleta.id} value={bicicleta.id}>
{bicicleta.modelo} · {bicicleta.precioHora.toFixed(2).replace('.', ',')} €/h
</option>
))}
</select>
</div>
<div className={estilos.campo}>
<label htmlFor="fechaInicio">Inicio de la reserva</label>
<input
id="fechaInicio"
name="fechaInicio"
type="datetime-local"
value={datos.fechaInicio}
onChange={manejarCambio}
/>
</div>
<div className={estilos.campo}>
<label htmlFor="horas">Duración (horas)</label>
<input
id="horas"
name="horas"
type="number"
min={1}
max={24}
step={1}
value={datos.horas}
onChange={manejarCambio}
/>
</div>
<div className={estilos.campoCasilla}>
<label>
<input
name="condiciones"
type="checkbox"
checked={datos.condiciones}
onChange={manejarCambio}
/>{' '}
Acepto las condiciones de uso de CicloUrbano
</label>
</div>
{elegida && (
<p className={estilos.total}>
{elegida.modelo} · {datos.horas || 0} h · <strong>{totalFormateado} €</strong>
</p>
)}
<button type="submit" className={estilos.enviar}>
Crear reserva
</button>
</form>
);
}
export default FormularioReserva;Repaso de las decisiones tomadas:
DATOS_INICIALESconhoras: 2reproduce el valor canónico de la reservares-01deldominio.js, así el formulario propone la duración habitual.- Un solo estado y un solo manejador para cuatro campos de tres tipos distintos, gracias al
namey al nombre de propiedad calculado. - La conversión por tipo está centralizada en
manejarCambio:checkedpara la casilla,Numberpara el campo numérico (respetando la cadena vacía) yvaluetal cual para el resto. disponibles,elegida,totalytotalFormateadoson valores derivados. No hay ni unuseStatede más: guardarlos en estado los expondría a desincronizarse, como aprendiste en 02-04.- El total solo aparece si hay bicicleta elegida, con el
&&de la lección 03-02. htmlForen cada<label>apunta aliddel campo. No es decoración: hace que al pulsar la etiqueta se enfoque el campo, y es lo que permite a un lector de pantalla anunciar de qué campo se trata. Se desarrolla en Accesibilidad en Componentes Interactivos.crypto.randomUUID()genera el identificador en el momento de crear la reserva, no durante el render. Es exactamente el consejo de la lección anterior sobre generar ids al crear el dato.
App recibe las reservas
// src/App.jsx (fragmento)
import { useState } from 'react';
import { bicicletas, estaciones, reservas as reservasIniciales } from './datos/dominio.js';
import FormularioReserva from './componentes/FormularioReserva.jsx';
function App() {
const [reservas, setReservas] = useState(reservasIniciales);
function manejarCrearReserva(reserva) {
console.log('Nueva reserva:', reserva);
setReservas((anteriores) => [...anteriores, reserva]); // array nuevo, sin mutar
}
return (
<main>
<FormularioReserva bicicletas={bicicletas} alCrearReserva={manejarCrearReserva} />
<p>Reservas registradas: {reservas.length}</p>
</main>
);
}Rellena el formulario y envíalo: en la consola aparece un objeto con la misma forma exacta que res-01, y el contador de reservas sube. El formulario se vacía solo, porque el estado ha vuelto a DATOS_INICIALES.
Todavía se puede enviar una reserva sin bicicleta, con una fecha en el pasado o sin aceptar las condiciones. Eso es deliberado: la validación completa es el contenido de la próxima lección.
Sobre bibliotecas de formularios: en proyectos grandes se usan herramientas como React Hook Form o Formik, que reducen el código repetitivo y optimizan los renders. Todas ellas se apoyan en los conceptos de esta lección, así que el orden correcto es aprender primero el mecanismo y luego, si el proyecto lo pide, adoptar la herramienta.
Errores Comunes y Consejos
valuesinonChange. El campo queda de solo lectura y React avisa. Añade el manejador, oreadOnlysi es intencionado.- Inicializar el estado con
undefinedonull. Provoca el aviso «changing an uncontrolled input to be controlled». Usa''para textos yfalsepara casillas. - Leer
evento.target.valueen una casilla. Devuelve"on", que siempre es truthy: la casilla no se podrá desmarcar. Usaevento.target.checked. - Poner
selecteden una<option>. En React el valor va en el<select>. React avisa si lo haces. - Escribir el contenido del
textareaentre etiquetas. En React va envalue. - Olvidar
preventDefault()enonSubmit. La página se recarga y se pierde todo el estado. El síntoma es un parpadeo y el formulario en blanco. - Poner el manejador de envío en el botón en vez de en el
<form>. Se pierde el envío con la teclaEnter. - Olvidar
type="button"en los botones auxiliares del formulario. Sin él sonsubmitpor defecto y enviarán el formulario al pulsarlos. - Que el
namedel campo no coincida con la clave del estado. El manejador genérico creará una clave nueva en silencio y el campo no responderá. - Olvidar los paréntesis en
(prev) => ({ … }). Sin ellos la flecha devuelveundefinedy el estado se rompe. - Guardar números como cadenas.
'3' + 1es'31'. Convierte conNumber()en el manejador, tratando la cadena vacía aparte. - Usar
evento.target.reset()en un formulario controlado. Vacía el DOM pero no el estado; React lo revierte en el siguiente render. - Consejo: define los valores iniciales en una constante fuera del componente. Sirve para el
useStatey para el reinicio, sin duplicar. - Consejo: no guardes en estado nada que puedas calcular. El total, la bicicleta elegida y la validez son valores derivados.
- Consejo: pon siempre
iden el campo yhtmlForen la etiqueta. Cuesta nada y arregla de golpe la usabilidad y la accesibilidad.
Ejercicios
Ejercicio 1
Este formulario tiene cinco errores. Identifícalos, explica el síntoma de cada uno y escribe la versión corregida.
function FormularioEstacion() {
const [nombre, setNombre] = useState();
const [barrio, setBarrio] = useState('Centro');
const [activa, setActiva] = useState(false);
function manejarEnvio() {
console.log({ nombre, barrio, activa });
}
return (
<form>
<input type="text" value={nombre} />
<select>
<option value="Centro" selected>Centro</option>
<option value="Norte">Norte</option>
</select>
<input
type="checkbox"
checked={activa}
onChange={(e) => setActiva(e.target.value)}
/>
<button type="submit" onClick={manejarEnvio}>Guardar</button>
</form>
);
}Ejercicio 2
Crea el componente FiltroCatalogo (src/componentes/FiltroCatalogo.jsx), un panel de filtros controlado con un solo objeto de estado y un manejador genérico. Debe incluir:
- Un campo de texto
busquedapara filtrar por modelo. - Un
selecttipocon las opciones «todos», «urbana», «electrica» y «carga». - Un grupo de radios
estadocon «todos», «disponible», «alquilada» y «mantenimiento». - Una casilla
soloConPlazas(booleana). - Un campo
precioMaximode tiponumberentre 0 y 10, constepde 0,5. - Un botón «Limpiar filtros» que restablezca los valores iniciales sin enviar el formulario.
El componente debe avisar al padre con alFiltrar(datos) en cada cambio.
Ejercicio 3
Amplía el FormularioReserva del apartado 12 con dos campos nuevos, manteniendo el mismo estado único:
- Un
selectestacionDevolucionque liste las tres estaciones deldominio.js, con la opción vacía «La misma de recogida». - Un
textareaobservacionesde un máximo de 300 caracteres, con un contador de caracteres restantes.
Después, haz que el objeto Reserva que se entrega al padre incluya ambos campos, y explica por qué el contador de caracteres no debe ser un useState aparte.
Soluciones
Solución 1.
Los cinco errores:
| Error | Síntoma |
|---|---|
useState() sin valor inicial |
value={undefined} hace que el campo empiece como no controlado; al escribir React avisa del cambio a controlado |
El <input type="text"> tiene value pero no onChange |
El campo no admite escritura y React avisa de que será de solo lectura |
selected en la <option> |
En React el valor va en el <select>; además falta el onChange, así que el desplegable no responde |
setActiva(e.target.value) en una casilla |
value vale "on", siempre truthy: la casilla no se puede desmarcar |
onClick en el botón y no onSubmit en el <form>, sin preventDefault |
No funciona el envío con Enter, y al pulsar el botón la página se recarga perdiendo todo |
// src/componentes/FormularioEstacion.jsx
import { useState } from 'react';
const BARRIOS = ['Centro', 'Norte', 'Ensanche'];
/**
* Alta de una estación de CicloUrbano.
* Props:
* - alGuardar (función, opcional): recibe { nombre, barrio, activa }
*/
function FormularioEstacion({ alGuardar }) {
const [nombre, setNombre] = useState('');
const [barrio, setBarrio] = useState('Centro');
const [activa, setActiva] = useState(false);
function manejarEnvio(evento) {
evento.preventDefault();
if (alGuardar) {
alGuardar({ nombre, barrio, activa });
}
}
return (
<form onSubmit={manejarEnvio}>
<label htmlFor="nombre">Nombre de la estación</label>
<input
id="nombre"
type="text"
value={nombre}
onChange={(evento) => setNombre(evento.target.value)}
/>
<label htmlFor="barrio">Barrio</label>
<select
id="barrio"
value={barrio}
onChange={(evento) => setBarrio(evento.target.value)}
>
{BARRIOS.map((nombreBarrio) => (
<option key={nombreBarrio} value={nombreBarrio}>
{nombreBarrio}
</option>
))}
</select>
<label>
<input
type="checkbox"
checked={activa}
onChange={(evento) => setActiva(evento.target.checked)}
/>{' '}
Estación en servicio
</label>
<button type="submit">Guardar</button>
</form>
);
}
export default FormularioEstacion;Solución 2.
// src/componentes/FiltroCatalogo.jsx
import { useState } from 'react';
const FILTROS_INICIALES = {
busqueda: '',
tipo: 'todos',
estado: 'todos',
soloConPlazas: false,
precioMaximo: 10
};
const TIPOS = ['todos', 'urbana', 'electrica', 'carga'];
const ESTADOS = ['todos', 'disponible', 'alquilada', 'mantenimiento'];
/**
* Panel de filtros del catálogo de CicloUrbano.
* Props:
* - alFiltrar (función, opcional): recibe el objeto de filtros en cada cambio
*/
function FiltroCatalogo({ alFiltrar }) {
const [filtros, setFiltros] = useState(FILTROS_INICIALES);
function aplicar(nuevos) {
setFiltros(nuevos);
if (alFiltrar) {
alFiltrar(nuevos);
}
}
function manejarCambio(evento) {
const { name, type, value, checked } = evento.target;
let valorFinal = value;
if (type === 'checkbox') {
valorFinal = checked;
} else if (type === 'number') {
valorFinal = value === '' ? '' : Number(value);
}
aplicar({ ...filtros, [name]: valorFinal });
}
function manejarLimpiar() {
aplicar(FILTROS_INICIALES);
}
return (
<form className="filtro-catalogo" onSubmit={(evento) => evento.preventDefault()}>
<div>
<label htmlFor="busqueda">Buscar modelo</label>
<input
id="busqueda"
name="busqueda"
type="text"
value={filtros.busqueda}
onChange={manejarCambio}
/>
</div>
<div>
<label htmlFor="tipo">Tipo</label>
<select id="tipo" name="tipo" value={filtros.tipo} onChange={manejarCambio}>
{TIPOS.map((tipo) => (
<option key={tipo} value={tipo}>
{tipo}
</option>
))}
</select>
</div>
<fieldset>
<legend>Estado</legend>
{ESTADOS.map((estado) => (
<label key={estado}>
<input
type="radio"
name="estado"
value={estado}
checked={filtros.estado === estado}
onChange={manejarCambio}
/>{' '}
{estado}
</label>
))}
</fieldset>
<label>
<input
name="soloConPlazas"
type="checkbox"
checked={filtros.soloConPlazas}
onChange={manejarCambio}
/>{' '}
Solo estaciones con plazas libres
</label>
<div>
<label htmlFor="precioMaximo">Precio máximo por hora</label>
<input
id="precioMaximo"
name="precioMaximo"
type="number"
min={0}
max={10}
step={0.5}
value={filtros.precioMaximo}
onChange={manejarCambio}
/>
</div>
{/* type="button" es imprescindible: sin él enviaría el formulario */}
<button type="button" onClick={manejarLimpiar}>
Limpiar filtros
</button>
</form>
);
}
export default FiltroCatalogo;Detalles: los radios comparten name="estado", así que el manejador genérico los trata como un campo de texto normal —el valor viene de value, no de checked—; el botón de limpiar lleva type="button"; y el onSubmit del formulario solo llama a preventDefault() para que pulsar Enter en el buscador no recargue la página.
Solución 3.
Los cambios sobre FormularioReserva:
const MAXIMO_OBSERVACIONES = 300;
const DATOS_INICIALES = {
bicicletaId: '',
fechaInicio: '',
horas: 2,
condiciones: false,
estacionDevolucion: '',
observaciones: ''
};
// Nueva prop: estaciones
function FormularioReserva({ bicicletas = [], estaciones = [], usuarioId = 'usr-01', alCrearReserva }) {
const [datos, setDatos] = useState(DATOS_INICIALES);
// Valor DERIVADO: se recalcula solo, nunca puede desincronizarse
const restantes = MAXIMO_OBSERVACIONES - datos.observaciones.length;
// … manejarCambio sin ningún cambio: el name y el spread ya cubren los campos nuevos … <div className={estilos.campo}>
<label htmlFor="estacionDevolucion">Estación de devolución</label>
<select
id="estacionDevolucion"
name="estacionDevolucion"
value={datos.estacionDevolucion}
onChange={manejarCambio}
>
<option value="">La misma de recogida</option>
{estaciones.map((estacion) => (
<option key={estacion.id} value={estacion.id}>
{estacion.nombre} ({estacion.barrio})
</option>
))}
</select>
</div>
<div className={estilos.campo}>
<label htmlFor="observaciones">Observaciones</label>
<textarea
id="observaciones"
name="observaciones"
rows={3}
maxLength={MAXIMO_OBSERVACIONES}
value={datos.observaciones}
onChange={manejarCambio}
/>
<small>Quedan {restantes} caracteres.</small>
</div>Y el objeto de reserva:
const reserva = {
id: `res-${crypto.randomUUID().slice(0, 8)}`,
bicicletaId: datos.bicicletaId,
usuario: usuarioId,
fechaInicio: datos.fechaInicio,
horas: Number(datos.horas),
estado: 'activa',
estacionDevolucion: datos.estacionDevolucion || null,
observaciones: datos.observaciones.trim()
};Por qué el contador no debe ser un useState aparte: porque restantes se puede calcular siempre a partir de datos.observaciones.length. Guardarlo en estado crearía una segunda fuente de verdad que habría que recordar actualizar en cada cambio, en el reinicio del formulario y en cualquier futura modificación del texto. Si alguien olvidara una de esas actualizaciones, el contador mostraría un número falso sin que nada fallara visiblemente. Es la regla de la lección 02-04: si se puede derivar, no es estado. Añadir el campo nuevo al manejarCambio no ha requerido ni una línea, precisamente porque el manejador es genérico.
Conclusión
Los formularios son el punto donde la aplicación deja de mostrar información y empieza a recogerla, y React resuelve el problema de la doble fuente de verdad con un patrón único: el componente controlado. Ya sabes que se construye con dos piezas inseparables —value que impone el estado y onChange que avisa del intento de cambio—, y por qué un value solitario deja el campo congelado con un aviso muy explícito en consola. Conoces las particularidades de cada tipo de campo: el textarea que lleva el contenido en value y no entre etiquetas; el select cuyo valor va en el elemento y no en la opción, con su variante múltiple que trabaja con arrays; las casillas que usan checked en lugar de value; los grupos de radios que comparten name y se marcan comparando con el estado; y los campos number y date, que siempre entregan cadenas y exigen una conversión explícita respetando el caso del campo vacío.
Sobre esa base has aprendido el patrón que hace manejable un formulario real: un solo objeto de estado, un solo manejador genérico y la combinación de name con nombre de propiedad calculado —{ ...anterior, [name]: valor }— que actualiza únicamente el campo tocado sin mutar nada. Y el envío correcto: onSubmit en el <form> para que funcione también con Enter, preventDefault() para que la página no se recargue y un reinicio que consiste simplemente en devolver el estado a su constante inicial.
CicloUrbano tiene por fin un FormularioReserva completo que construye un objeto Reserva con la forma exacta del dominio.js y lo entrega al padre, mostrando de paso el total calculado a partir del precio de la bicicleta elegida. Pero acepta cualquier cosa: se puede enviar sin elegir bicicleta, con una fecha del año pasado, con cero horas o sin aceptar las condiciones. Un formulario que no valida no está terminado. En Validación de Formularios y Componentes No Controlados verás la otra mitad de la historia: cuándo validar y cómo hacerlo sin gritarle a quien todavía está escribiendo, qué ofrece y qué no ofrece la validación nativa del navegador, y cuándo conviene renunciar al control y dejar que sea el DOM quien guarde el valor.
Curso de React
Módulo 1: Introducción a React
- ¿Qué es React?
- Configuración del Entorno de Desarrollo
- Hola Mundo en React
- JSX: Extensión de Sintaxis de JavaScript
- Cómo Renderiza React: Virtual DOM y Reconciliación
Módulo 2: Componentes de React
- Entendiendo los Componentes
- Componentes Funcionales vs de Clase
- Props: Pasando Datos a Componentes
- State: Gestión del Estado del Componente
- Estilos en los Componentes: CSS, Módulos y Utilidades
Módulo 3: Trabajando con Eventos
- Manejo de Eventos en React
- Renderizado Condicional
- Listas y Claves
- Formularios y Componentes Controlados
- Validación de Formularios y Componentes No Controlados
- Accesibilidad en Componentes Interactivos
Módulo 4: Conceptos Avanzados de Componentes
- Elevando el Estado
- Composición vs Herencia
- Métodos del Ciclo de Vida de React
- Hooks: Introducción y Uso Básico
- Límites de Error: Capturar Fallos en la Interfaz
Módulo 5: Hooks de React
- Hook useState
- Hook useEffect
- Hook useRef y Acceso al DOM
- Hook useContext
- Hook useReducer
- Hooks Personalizados
Módulo 6: Enrutamiento en React
- Introducción a React Router
- Configuración de React Router
- Rutas Anidadas
- Navegación Programática
- Rutas Protegidas y Control de Acceso
Módulo 7: Gestión del Estado
- Introducción a la Gestión del Estado
- API de Contexto
- Redux: Introducción y Configuración
- Redux: Acciones y Reductores
- Redux: Conectando a React
- Estado del Servidor: Peticiones, Caché y Sincronización
Módulo 8: Optimización del Rendimiento
- Técnicas de Optimización del Rendimiento en React
- Memorización con React.memo
- Hooks useMemo y useCallback
- División de Código y Carga Perezosa
- Medir el Rendimiento con React DevTools Profiler
Módulo 9: Pruebas en React
- Introducción a las Pruebas
- Pruebas Unitarias con Jest
- Pruebas de Componentes con React Testing Library
- Pruebas de Código Asíncrono y Simulación de APIs
- Pruebas de Extremo a Extremo con Cypress
Módulo 10: Temas Avanzados
- Renderizado del Lado del Servidor (SSR) con Next.js
- Generación de Sitios Estáticos (SSG) con Next.js
- Suspense y React Server Components
- TypeScript con React
- React Native: Creación de Aplicaciones Móviles
