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

  1. El conflicto: dos fuentes de verdad
  2. Qué es un componente controlado
  3. value + onChange paso a paso
  4. Un value sin onChange: el campo congelado
  5. Campos de texto y textarea
  6. El desplegable select, simple y múltiple
  7. Casillas de verificación con checked
  8. Grupos de botones de radio
  9. number y date: la conversión de tipos
  10. Un solo estado para todo el formulario
  11. El envío: onSubmit y preventDefault
  12. CicloUrbano: FormularioReserva

  1. 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.

  1. 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».

  1. value + onChange paso a paso

Los dos ingredientes son inseparables. Analicémoslos por separado.

value: el estado manda

<input type="text" value={texto} />

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

onChange={(evento) => setTexto(evento.target.value)}

Tres detalles importantes de esta línea:

  • evento.target es el nodo del DOM que ha originado el evento: el <input>. Como aprendiste en 03-01, es distinto de currentTarget, aunque en un campo suelto coincidan.
  • .value es siempre una cadena de texto, incluso en un <input type="number">. Volveremos sobre esto en el apartado 9.
  • onChange en React se dispara en cada pulsación de tecla, a diferencia del evento change nativo 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.

  1. Un value sin onChange: el campo congelado

Este 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.

// ✘ MAL: value fijo sin manejador
<input type="text" value={texto} />

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 value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly.

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

  1. Campos de texto y textarea

Los 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:

<!-- HTML clásico -->
<textarea rows="4">Texto inicial</textarea>

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;

  1. El desplegable select, simple y múltiple

Aquí 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 value del select debe coincidir con el value de 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 map necesitan key, como cualquier lista (lección 03-03).
  • evento.target.value siempre es una cadena. Si tus identificadores fueran numéricos, harían falta conversiones; con los est-01 de 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.

  1. Casillas de verificación con checked

Una 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í.

  1. 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».

  1. number y date: la conversión de tipos

Aquí 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 suerte

La 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.

  1. 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

{ ...anterior, [name]: valorFinal }

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

setDatos((anterior) => ({ ...anterior, [name]: valorFinal }));

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

  1. El envío: onSubmit y preventDefault

El 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:

  1. Pulsando el botón type="submit".
  2. Pulsando Enter en cualquier campo de texto del formulario.
  3. 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_INICIALES no se debe mutar nunca. Como setDatos siempre 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.

  1. CicloUrbano: FormularioReserva

Es 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_INICIALES con horas: 2 reproduce el valor canónico de la reserva res-01 del dominio.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 name y al nombre de propiedad calculado.
  • La conversión por tipo está centralizada en manejarCambio: checked para la casilla, Number para el campo numérico (respetando la cadena vacía) y value tal cual para el resto.
  • disponibles, elegida, total y totalFormateado son valores derivados. No hay ni un useState de 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.
  • htmlFor en cada <label> apunta al id del 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

  • value sin onChange. El campo queda de solo lectura y React avisa. Añade el manejador, o readOnly si es intencionado.
  • Inicializar el estado con undefined o null. Provoca el aviso «changing an uncontrolled input to be controlled». Usa '' para textos y false para casillas.
  • Leer evento.target.value en una casilla. Devuelve "on", que siempre es truthy: la casilla no se podrá desmarcar. Usa evento.target.checked.
  • Poner selected en una <option>. En React el valor va en el <select>. React avisa si lo haces.
  • Escribir el contenido del textarea entre etiquetas. En React va en value.
  • Olvidar preventDefault() en onSubmit. 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 tecla Enter.
  • Olvidar type="button" en los botones auxiliares del formulario. Sin él son submit por defecto y enviarán el formulario al pulsarlos.
  • Que el name del 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 devuelve undefined y el estado se rompe.
  • Guardar números como cadenas. '3' + 1 es '31'. Convierte con Number() 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 useState y 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 id en el campo y htmlFor en 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 busqueda para filtrar por modelo.
  • Un select tipo con las opciones «todos», «urbana», «electrica» y «carga».
  • Un grupo de radios estado con «todos», «disponible», «alquilada» y «mantenimiento».
  • Una casilla soloConPlazas (booleana).
  • Un campo precioMaximo de tipo number entre 0 y 10, con step de 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:

  1. Un select estacionDevolucion que liste las tres estaciones del dominio.js, con la opción vacía «La misma de recogida».
  2. Un textarea observaciones de 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

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados