El módulo anterior terminó con una deuda muy concreta: SelectorTipo guarda el tipo elegido en su propio estado y avisa al padre, pero el catálogo sigue mostrando las cinco bicicletas pase lo que pase; y TarjetaBicicleta avisa con alSeleccionar, pero App solo puede hacer un console.log porque no tiene dónde guardar la bicicleta seleccionada. Las dos cosas fallan por la misma razón: el estado es privado de cada componente, y dos hermanos nunca pueden verse el estado el uno al otro. En esta lección aprenderás la técnica que resuelve esto —elevar el estado (lifting state up)—, la aplicarás para que el filtro de CicloUrbano funcione de verdad, entenderás por qué duplicar el mismo dato en dos sitios es una fuente garantizada de errores y verás el precio que se paga al elevar: la perforación de props.

Contenido

  1. El problema: dos hermanos y un dato compartido
  2. La regla: subir al ancestro común más cercano
  3. El refactor de CicloUrbano, paso a paso
  4. App con estado: tipoElegido y bicicletaSeleccionada
  5. Estado derivado: bicicletasVisibles no es estado
  6. El flujo unidireccional, visto entero
  7. Una única fuente de verdad: el error de duplicar
  8. Componentes controlados y no controlados, a nivel de componente
  9. Qué NO conviene elevar
  10. El coste de elevar: la perforación de props

  1. El problema: dos hermanos y un dato compartido

Este es el árbol de CicloUrbano tal como quedó al final del módulo 3:

flowchart TD
    APP["App<br/>(sin estado)"] --> CAB["Cabecera"]
    APP --> RES["ResumenFlota"]
    APP --> SEL["SelectorTipo<br/><b>estado: tipoElegido</b>"]
    APP --> LIS["ListaBicicletas<br/>(sin estado)"]
    APP --> PIE["PieDePagina"]
    LIS --> T1["TarjetaBicicleta"]
    LIS --> T2["TarjetaBicicleta"]
    SEL -. "¿cómo llega<br/>tipoElegido hasta aquí?" .-x LIS

La flecha tachada es el problema. tipoElegido vive dentro de SelectorTipo, y desde ahí el dato solo puede viajar hacia abajo (a los hijos de SelectorTipo, que no tiene) o hacia arriba (avisando al padre con una prop de función). Lo que no existe en React es un camino lateral: un componente no puede leer el estado de su hermano.

Y no es una limitación caprichosa. Si ListaBicicletas pudiera leer el estado de SelectorTipo, ambos quedarían acoplados: no podrías reutilizar la lista en otra pantalla sin arrastrar el selector, y para entender por qué la lista muestra lo que muestra tendrías que leer un componente que no aparece en su código. React elige la restricción a propósito, y a cambio ofrece una regla simple para resolverlo.

  1. La regla: subir al ancestro común más cercano

Cuando dos o más componentes necesitan el mismo dato, ese dato debe vivir en el estado del ancestro común más cercano, y bajar a cada uno como prop.

La técnica se llama elevar el estado y consta de tres movimientos:

Paso Qué se hace En CicloUrbano
1. Identificar ¿Qué componentes necesitan el dato? SelectorTipo (para pintar el botón activo) y ListaBicicletas (para filtrar)
2. Localizar ¿Cuál es su ancestro común más cercano? App
3. Mover El estado sube; el dato baja como prop; el hijo avisa con un callback tipoElegido pasa de SelectorTipo a App

El componente que pierde el estado no se queda mudo: recibe dos props en su lugar.

  • El valor actual (tipoElegido), para pintarse.
  • Una función de aviso (alCambiarTipo), para pedirle al padre que lo cambie.

Si esto te suena, es porque es exactamente el patrón que usaste en 03-04 con un <input> controlado: value + onChange. La diferencia es que allí el componente controlado era una etiqueta del DOM y aquí es un componente tuyo. El patrón es el mismo, y volveremos sobre ello en el apartado 8.

  1. El refactor de CicloUrbano, paso a paso

Paso 1: SelectorTipo deja de guardar estado

Esta es la versión de 03-01, la que vamos a sustituir:

// src/componentes/SelectorTipo.jsx  — VERSIÓN A SUSTITUIR
function SelectorTipo({ alCambiarTipo }) {
  const [tipoElegido, setTipoElegido] = useState('todos');   // <- el estado que estorba

  function manejarSeleccionTipo(tipo) {
    setTipoElegido(tipo);
    if (alCambiarTipo) {
      alCambiarTipo(tipo);
    }
  }
  // …
}

Y esta es la nueva:

// src/componentes/SelectorTipo.jsx
import { clases } from '../utilidades/clases.js';
import estilos from './SelectorTipo.module.css';

const TIPOS = ['todos', 'urbana', 'electrica', 'carga'];

const ETIQUETAS = {
  todos: 'Todas',
  urbana: 'Urbanas',
  electrica: 'Eléctricas',
  carga: 'De carga'
};

/**
 * Selector del tipo de bicicleta. Componente CONTROLADO: no guarda estado.
 * Props:
 *  - tipoElegido   (cadena, obligatorio): el tipo activo ahora mismo
 *  - alCambiarTipo (función, obligatorio): recibe el tipo pulsado
 */
function SelectorTipo({ tipoElegido, alCambiarTipo }) {
  function manejarSeleccionTipo(tipo) {
    alCambiarTipo(tipo);
  }

  function manejarTeclaLimpiar(evento) {
    if (evento.key === 'Escape') {
      alCambiarTipo('todos');
    }
  }

  return (
    <div className={estilos.selector} onKeyDown={manejarTeclaLimpiar}>
      <p className={estilos.titulo}>Filtrar por tipo:</p>

      {TIPOS.map((tipo) => (
        <button
          key={tipo}
          type="button"
          className={clases(estilos.boton, tipo === tipoElegido && estilos.activo)}
          onClick={() => manejarSeleccionTipo(tipo)}
          aria-pressed={tipo === tipoElegido}
        >
          {ETIQUETAS[tipo]}
        </button>
      ))}

      <p className={estilos.seleccion}>
        Selección actual: <strong>{ETIQUETAS[tipoElegido]}</strong>
      </p>
    </div>
  );
}

export default SelectorTipo;

Cambios y por qué:

  • Desaparecen useState y su import. El componente ya no recuerda nada: todo lo que pinta sale de sus props. Se ha convertido en un componente de presentación puro, de los que viste en 02-01.
  • alCambiarTipo pasa de opcional a obligatorio. Antes era un extra: el selector funcionaba sin él porque se pintaba con su propio estado. Ahora es la única vía de cambio; sin esa prop el componente sería un adorno que no responde. Documéntalo en el bloque /** Props: … */.
  • El botón activo se decide con tipoElegido, que ahora llega de fuera. El JSX no ha cambiado ni una línea: le da igual de dónde venga el valor.
  • aria-pressed comunica el estado del botón a las tecnologías de asistencia, siguiendo lo aprendido en 03-06: la selección no puede informarse solo con el color del CSS.

Paso 2: App recoge el estado

// src/App.jsx
import { useState } from 'react';
import { bicicletas, estaciones } from './datos/dominio.js';
import Cabecera from './componentes/Cabecera.jsx';
import ResumenFlota from './componentes/ResumenFlota.jsx';
import SelectorTipo from './componentes/SelectorTipo.jsx';
import ListaBicicletas from './componentes/ListaBicicletas.jsx';
import PanelReserva from './componentes/PanelReserva.jsx';
import PieDePagina from './componentes/PieDePagina.jsx';

function App() {
  const [tipoElegido, setTipoElegido] = useState('todos');
  const [bicicletaSeleccionada, setBicicletaSeleccionada] = useState(null);

  // Valor DERIVADO: se recalcula en cada render, no se guarda
  const bicicletasVisibles =
    tipoElegido === 'todos'
      ? bicicletas
      : bicicletas.filter((bicicleta) => bicicleta.tipo === tipoElegido);

  function manejarCambioTipo(tipo) {
    setTipoElegido(tipo);
  }

  function manejarSeleccionBicicleta(bicicleta) {
    setBicicletaSeleccionada(bicicleta);
  }

  function manejarReserva(bicicleta) {
    setBicicletaSeleccionada(bicicleta);
  }

  function manejarConfirmacion({ bicicleta, horas, total }) {
    console.log('Reserva confirmada', bicicleta.id, horas, total);
    setBicicletaSeleccionada(null);
  }

  return (
    <>
      <Cabecera />
      <main>
        <ResumenFlota flota={bicicletas} />

        <SelectorTipo tipoElegido={tipoElegido} alCambiarTipo={manejarCambioTipo} />

        <ListaBicicletas
          bicicletas={bicicletasVisibles}
          estaciones={estaciones}
          alSeleccionar={manejarSeleccionBicicleta}
          alReservar={manejarReserva}
        />

        <PanelReserva
          bicicleta={bicicletaSeleccionada}
          horas={2}
          alConfirmar={manejarConfirmacion}
        />
      </main>
      <PieDePagina />
    </>
  );
}

export default App;

Lo que ha ocurrido aquí es todo el módulo condensado en un fichero:

  • App tiene por fin estado, y son exactamente dos datos: qué filtro está activo y qué bicicleta está seleccionada. Ni uno más.
  • ListaBicicletas recibe bicicletasVisibles, no bicicletas. El componente no se ha modificado: sigue recibiendo un array y pintándolo con map. Ni siquiera sabe que existe un filtro, y esa ignorancia es una virtud, porque lo hace reutilizable en cualquier pantalla.
  • PanelReserva recibe por fin una bicicleta de verdad. Los retornos anticipados que escribiste en 03-02 —«Selecciona una bicicleta del catálogo» cuando la prop falta— dejan de ser una hipótesis: con bicicletaSeleccionada a null al arrancar, ese es literalmente el primer estado que ve la persona usuaria.
  • manejarConfirmacion limpia la selección poniéndola a null, con lo que el panel vuelve a su mensaje inicial. Un ciclo completo, sin trucos.

Y ListaBicicletas se beneficia de algo que ya tenía escrito: su estado vacío. Si filtras por «De carga» y la única bicicleta de carga está en mantenimiento, el bicicletas.length === 0 que programaste en 03-03 se dispara y muestra el mensaje. No has tenido que escribir nada nuevo.

El árbol, después

flowchart TD
    APP["App<br/><b>estado: tipoElegido</b><br/><b>estado: bicicletaSeleccionada</b>"]
    APP -- "tipoElegido ▼" --> SEL["SelectorTipo<br/>(sin estado)"]
    APP -- "bicicletasVisibles ▼" --> LIS["ListaBicicletas"]
    APP -- "bicicleta ▼" --> PAN["PanelReserva"]
    SEL -. "alCambiarTipo(tipo) ▲" .-> APP
    LIS --> TAR["TarjetaBicicleta"]
    TAR -. "alSeleccionar(bicicleta) ▲" .-> APP

Los datos bajan por props (flechas continuas) y los avisos suben por funciones (flechas discontinuas). No hay ninguna flecha horizontal: los hermanos siguen sin hablarse, se comunican a través del padre.

  1. App con estado: qué guardar y qué no

Cuando el estado sube, la tentación es subirlo todo. Aplica este filtro a cada dato antes de convertirlo en estado del padre:

Pregunta Si la respuesta es sí…
¿Cambia con el tiempo por acción de la persona usuaria? Puede ser estado
¿Lo necesita más de un componente? Debe vivir en el ancestro común
¿Se puede calcular a partir de otro estado o de las props? No es estado: es un valor derivado
¿Solo lo usa un componente y nadie más? Déjalo dentro de ese componente

En CicloUrbano, tipoElegido y bicicletaSeleccionada pasan las dos primeras preguntas y no pasan la tercera: no hay forma de deducirlos de nada. Son estado legítimo.

Un detalle sobre bicicletaSeleccionada: guardamos el objeto entero, no su id. Con datos estáticos importados de dominio.js funciona perfectamente. Cuando los datos vengan de un servidor y puedan actualizarse (Módulo 7), la práctica recomendada será guardar el id y buscar el objeto al vuelo, porque un objeto guardado en estado es una copia congelada que no se entera si el original cambia. Es la misma advertencia sobre duplicación que viene en el apartado 7.

  1. Estado derivado: bicicletasVisibles no es estado

Este es el error más común al elevar. La versión incorrecta:

// INCORRECTO: dos estados que hay que mantener sincronizados a mano
const [tipoElegido, setTipoElegido] = useState('todos');
const [bicicletasVisibles, setBicicletasVisibles] = useState(bicicletas);

function manejarCambioTipo(tipo) {
  setTipoElegido(tipo);
  setBicicletasVisibles(
    tipo === 'todos' ? bicicletas : bicicletas.filter((b) => b.tipo === tipo)
  );
}

Funciona… hasta que alguien añade otra forma de cambiar el filtro y se olvida de la segunda línea. En ese momento el selector dice «Eléctricas» y la lista muestra todas las bicicletas. El fallo no está en el filtrado: está en que la misma información se guarda dos veces y nada garantiza que coincidan.

La versión correcta ya la has visto: una sola línea, sin estado.

const bicicletasVisibles =
  tipoElegido === 'todos'
    ? bicicletas
    : bicicletas.filter((bicicleta) => bicicleta.tipo === tipoElegido);

Se recalcula en cada render, que es exactamente cuando hace falta, y es imposible que se desincronice porque no hay nada que sincronizar. Es la misma lección de 02-04 —estado frente a valor derivado— aplicada ahora a un componente contenedor.

«¿No es ineficiente filtrar en cada render?» Con cinco bicicletas, o con quinientas, no. filter sobre un array pequeño cuesta microsegundos, mucho menos que la complejidad de mantener dos estados a mano. Si algún día el cálculo es realmente caro y lo has medido, existe useMemo (08-03). Medir primero, optimizar después.

  1. El flujo unidireccional, visto entero

Sigamos un clic desde el principio hasta el final. Alguien pulsa «Eléctricas»:

sequenceDiagram
    participant U as Persona usuaria
    participant S as SelectorTipo
    participant A as App
    participant L as ListaBicicletas
    U->>S: clic en «Eléctricas»
    S->>A: alCambiarTipo('electrica')
    A->>A: setTipoElegido('electrica')
    Note over A: React programa un nuevo render
    A->>A: bicicletasVisibles = filter(...)
    A->>S: tipoElegido = 'electrica' (botón activo)
    A->>L: bicicletas = [bici-002, bici-005]
    L->>U: dos tarjetas en pantalla

Fíjate en un detalle importante: SelectorTipo no cambia nada por sí mismo. Pulsas el botón y no ocurre nada visible hasta que App actualiza su estado y vuelve a renderizar. El selector solo pide el cambio. Si App decidiera ignorar la petición —por ejemplo, prohibiendo el filtro «De carga» a los clientes—, el botón no se activaría. Toda la autoridad está en un único sitio, y eso hace que el comportamiento sea predecible y depurable: si algo se ve mal, el estado equivocado está en App.

Este ciclo cerrado es lo que se conoce como flujo de datos unidireccional, y es la razón por la que una aplicación React grande se puede razonar leyendo de arriba abajo.

  1. Una única fuente de verdad: el error de duplicar

El principio se enuncia así: cada dato debe tener exactamente un dueño. Cualquier otro componente que lo necesite lo recibe como prop; nadie hace una copia local.

Veamos el fallo con un ejemplo reproducible. Imagina que, tras elevar el estado, dejas también una copia dentro del hijo «por comodidad»:

// INCORRECTO: el hijo copia la prop en su propio estado
function SelectorTipo({ tipoElegido, alCambiarTipo }) {
  const [tipoLocal, setTipoLocal] = useState(tipoElegido);   // <- la copia venenosa

  function manejarSeleccionTipo(tipo) {
    setTipoLocal(tipo);
    alCambiarTipo(tipo);
  }
  // … pinta el botón activo usando tipoLocal
}

A primera vista funciona. Pero contiene dos bombas de relojería:

  1. useState(tipoElegido) solo lee la prop en el primer render. Es el valor inicial, no un vínculo permanente. Si mañana App añade un botón «Restablecer filtros» que hace setTipoElegido('todos'), el padre dirá «todos» y el selector seguirá mostrando «Eléctricas» activo. Para siempre.
  2. Hay dos verdades a la vez. ¿Cuál es el tipo elegido de verdad? Depende de a quién preguntes. Y depurar eso significa mirar dos componentes en las DevTools y comparar.

La regla práctica, sin excepciones que merezca la pena aprenderse ahora:

Situación Qué hacer
El hijo necesita mostrar un dato del padre Recibirlo como prop y usarlo directamente
El hijo necesita cambiar ese dato Llamar a una función que le pasa el padre
El hijo necesita un dato que nadie más usa useState dentro del hijo, sin culpa
El hijo necesita «inicializarse» con una prop y luego ir por libre Replantea el diseño; casi siempre el dato debía estar arriba

  1. Componentes controlados y no controlados, a nivel de componente

En 03-04 y 03-05 aplicaste estos términos a los campos de un formulario. Ahora se aplican igual a componentes que escribes tú, y la distinción es exactamente la misma.

Componente controlado Componente no controlado
Dónde vive el dato En el padre Dentro del propio componente
Qué props recibe El valor + un callback de cambio Como mucho, un valor inicial
Quién manda El padre El componente
Ventaja El padre puede leerlo, coordinarlo, restablecerlo Se usa con una sola línea, sin ceremonia
Inconveniente El padre tiene que declarar estado Nadie más puede ver ni cambiar el dato
Ejemplo en CicloUrbano SelectorTipo (después de esta lección) Acordeon con abiertoPorDefecto

Un mismo componente puede admitir las dos modalidades, y es un patrón que verás en muchas bibliotecas. La técnica: si la prop de valor llega definida, el componente obedece al padre; si no, gestiona su propio estado.

// src/componentes/Acordeon.jsx

/**
 * Sección plegable. Admite dos modos:
 *  - Controlado:    <Acordeon abierto={abierto} alAlternar={...} />
 *  - No controlado: <Acordeon abiertoPorDefecto />
 * Props:
 *  - titulo             (cadena, obligatorio)
 *  - children           (contenido)
 *  - abierto            (booleano, opcional): activa el modo controlado
 *  - abiertoPorDefecto  (booleano, opcional): valor inicial del modo no controlado
 *  - alAlternar         (función, opcional): recibe el nuevo valor
 */
function Acordeon({ titulo, children, abierto, abiertoPorDefecto = false, alAlternar }) {
  const [abiertoInterno, setAbiertoInterno] = useState(abiertoPorDefecto);

  const esControlado = abierto !== undefined;
  const estaAbierto = esControlado ? abierto : abiertoInterno;

  function manejarAlternar() {
    if (!esControlado) {
      setAbiertoInterno(!estaAbierto);
    }
    if (alAlternar) {
      alAlternar(!estaAbierto);
    }
  }

  return (
    <section>
      <h3>
        <button type="button" onClick={manejarAlternar} aria-expanded={estaAbierto}>
          {titulo}
        </button>
      </h3>
      {estaAbierto && <div>{children}</div>}
    </section>
  );
}

export default Acordeon;

Las tres líneas clave:

  • const esControlado = abierto !== undefined; El contrato es explícito: pasar la prop significa «yo mando». Se compara con undefined y no con un valor falsy, porque abierto={false} es un valor perfectamente válido que sí debe activar el modo controlado.
  • const estaAbierto = esControlado ? abierto : abiertoInterno; El resto del componente usa siempre esta variable y no se entera de qué modo está activo. Toda la bifurcación cabe en una línea.
  • if (!esControlado) setAbiertoInterno(...) En modo controlado el componente no toca su estado interno: solo avisa. Actualizar ambos sería duplicar la verdad, el error del apartado 7.

No hace falta que todos tus componentes admitan las dos modalidades —añade complejidad— pero conviene saber leerlo, porque es el diseño de casi cualquier componente de biblioteca que uses.

  1. Qué NO conviene elevar

Elevar es una herramienta, no un dogma. Subir estado que nadie más necesita empeora el código: obliga a App a re-renderizar el árbol entero por cambios que solo afectan a un rincón, y llena el componente raíz de datos irrelevantes.

Casos claros de estado que debe quedarse abajo:

Estado local Por qué se queda Dónde vive
Texto tecleado en un buscador con retardo (debounce) El padre solo necesita el término ya estabilizado, no cada pulsación En el buscador; se avisa arriba al estabilizarse
Si un panel está plegado o desplegado Nadie más decide nada con ese dato En el panel
Si un menú desplegable está abierto Puramente visual y efímero En el menú
El índice de la pestaña activa dentro de un widget aislado Detalle de presentación En el widget
Si el ratón está encima de una tarjeta Efímero y por instancia En la tarjeta

El buscador con retardo lo ilustra bien:

// src/componentes/BuscadorBicicletas.jsx
/**
 * Props:
 *  - alBuscar (función): recibe el término, pero solo cuando deja de escribirse
 */
function BuscadorBicicletas({ alBuscar }) {
  const [texto, setTexto] = useState('');   // ESTADO LOCAL: no sube

  function manejarCambio(evento) {
    setTexto(evento.target.value);
    // el aviso al padre se hará con retardo; el mecanismo lo verás en 05-02
  }

  return (
    <input
      type="search"
      value={texto}
      onChange={manejarCambio}
      aria-label="Buscar bicicletas por modelo"
    />
  );
}

Si texto viviera en App, cada tecla pulsada provocaría un render del árbol completo, incluido el catálogo entero. Al quedarse dentro, solo se repinta el <input>, y el padre se entera una vez, cuando hay algo que hacer.

La pregunta de control es siempre la misma: ¿alguien más necesita este dato para pintarse o para decidir algo? Si la respuesta es no, se queda donde está. Y si mañana la respuesta cambia, elevarlo es un refactor de diez minutos: no hay que anticiparse.

  1. El coste de elevar: la perforación de props

Elevar tiene un precio, y es honesto conocerlo. Cuando el ancestro común está lejos, el dato tiene que atravesar todos los componentes intermedios, aunque a ellos no les sirva de nada. Se llama perforación de props (prop drilling).

Imagina que CicloUrbano crece y la tarjeta necesita saber el rol del usuario (usr-02 es operario y puede marcar mantenimiento):

// App -> Catalogo -> ListaBicicletas -> TarjetaBicicleta

function App() {
  const [usuario, setUsuario] = useState(usuarios[0]);
  return <Catalogo usuario={usuario} />;              // nivel 1
}

function Catalogo({ usuario }) {                      // no lo usa, solo lo pasa
  return <ListaBicicletas usuario={usuario} bicicletas={bicicletas} />;
}

function ListaBicicletas({ usuario, bicicletas }) {   // tampoco lo usa
  return bicicletas.map((bicicleta) => (
    <TarjetaBicicleta key={bicicleta.id} bicicleta={bicicleta} usuario={usuario} />
  ));
}

function TarjetaBicicleta({ bicicleta, usuario }) {   // aquí sí se usa, por fin
  return usuario.rol === 'operario' ? <BotonMantenimiento /> : null;
}
flowchart TD
    A["App<br/>estado: usuario"] -- "usuario" --> B["Catalogo<br/>❌ no lo usa"]
    B -- "usuario" --> C["ListaBicicletas<br/>❌ no lo usa"]
    C -- "usuario" --> D["TarjetaBicicleta<br/>✅ lo usa"]

Los síntomas son reconocibles:

  • Componentes intermedios con props que solo reenvían, sin usarlas.
  • Añadir un dato nuevo obliga a tocar cuatro ficheros para que llegue a uno.
  • Renombrar la prop obliga a un buscar-y-reemplazar por toda la cadena.
  • Los componentes intermedios pierden reutilización: exigen una prop que no les importa.

Con dos niveles no es un problema, y no conviene arreglarlo antes de que duela. Con cuatro o cinco, sí. React tiene una respuesta para esto: la API de contexto, que permite poner un valor a disposición de todo un subárbol sin ir de mano en mano. La verás en useContext y, a escala de aplicación entera, en el Módulo 7, donde también aparecen los gestores de estado externos. Aquí solo hemos puesto nombre al problema.

Errores Comunes y Consejos

  • Copiar una prop en el estado del hijo. useState(props.valor) solo lee la prop una vez, en el primer render. A partir de ahí las dos copias se separan y nunca vuelven a coincidir. Si necesitas leer y cambiar el dato, recíbelo por prop y avisa hacia arriba.
  • Guardar en estado lo que se puede calcular. bicicletasVisibles, totales, contadores, textos formateados: todo eso es derivado. Cada estado añadido es una oportunidad más de desincronización.
  • Elevar demasiado alto «por si acaso». El destino correcto es el ancestro común más cercano, no la raíz. Poner todo en App convierte el componente raíz en un vertedero y provoca renders innecesarios.
  • Olvidarse de pasar el callback. Si conviertes un componente en controlado y olvidas la prop de cambio, obtendrás un componente inerte que no responde a los clics, sin ningún error en consola. Documenta las props obligatorias y considera un aviso en desarrollo.
  • Pasar el valor pero no el manejador (o al revés). Un componente controlado necesita las dos props: con una sola queda a medias, igual que un <input value> sin onChange queda de solo lectura.
  • Consejo: mira las DevTools. Selecciona App en React DevTools y verás sus dos estados cambiando en directo mientras pulsas filtros y tarjetas. Es la mejor forma de confirmar que la fuente de la verdad está donde crees.
  • Consejo: nombra las props del contrato. El par tipoElegido / alCambiarTipo se lee como value / onChange. Mantener esa simetría en todo el proyecto hace que los componentes se usen sin consultar su código.

Ejercicios

Ejercicio 1. Eleva el estado del PanelReserva. Ahora mismo recibe horas como prop fija con valor 2, así que no se puede cambiar. Haz que App guarde horasReserva en su estado (valor inicial 2) y que PanelReserva reciba horas y una nueva prop alCambiarHoras, con dos botones («−» y «+») que nunca bajen de 1 hora. El total debe recalcularse solo. Explica por qué el total no debe ser estado.

Ejercicio 2. Añade a App un contador de resultados accesible. Debajo del SelectorTipo, muestra un texto del tipo «Mostrando 2 de 5 bicicletas», con la concordancia correcta en singular y plural, dentro de un elemento con aria-live="polite" (03-06). No añadas ningún estado nuevo: el texto debe salir enteramente de bicicletasVisibles y bicicletas.

Ejercicio 3. Detecta y corrige la desincronización. Este componente tiene el error del apartado 7. Descríbelo con un caso concreto que lo haga fallar y reescríbelo correctamente.

function ResumenSeleccion({ bicicletaSeleccionada }) {
  const [modelo, setModelo] = useState(
    bicicletaSeleccionada ? bicicletaSeleccionada.modelo : 'Ninguna'
  );

  return <p>Bicicleta seleccionada: {modelo}</p>;
}

Soluciones

Solución 1.

// src/App.jsx (fragmento)
const [horasReserva, setHorasReserva] = useState(2);

function manejarCambioHoras(nuevasHoras) {
  setHorasReserva(Math.max(1, nuevasHoras));
}

<PanelReserva
  bicicleta={bicicletaSeleccionada}
  horas={horasReserva}
  alCambiarHoras={manejarCambioHoras}
  alConfirmar={manejarConfirmacion}
/>
// src/componentes/PanelReserva.jsx (fragmento del bloque final)
/**
 * Props:
 *  - bicicleta      (objeto, opcional)
 *  - horas          (número, opcional, por defecto 1)
 *  - alCambiarHoras (función, opcional): recibe el nuevo número de horas
 *  - alConfirmar    (función, opcional): recibe { bicicleta, horas, total }
 */
function PanelReserva({ bicicleta, horas = 1, alCambiarHoras, alConfirmar }) {
  // … cláusulas de guarda de 03-02, sin cambios …

  const total = bicicleta.precioHora * horas;          // DERIVADO
  const totalFormateado = total.toFixed(2).replace('.', ',');

  return (
    <div className={estilos.panel}>
      <h3>{bicicleta.modelo}</h3>

      <div className={estilos.horas}>
        <button
          type="button"
          onClick={() => alCambiarHoras(horas - 1)}
          disabled={horas <= 1}
          aria-label="Quitar una hora"
        >
          −
        </button>
        <span>{horas === 1 ? '1 hora' : `${horas} horas`}</span>
        <button type="button" onClick={() => alCambiarHoras(horas + 1)} aria-label="Añadir una hora">
          +
        </button>
      </div>

      <p className={estilos.total}>Total: {totalFormateado} €</p>
      <button type="button" onClick={() => alConfirmar({ bicicleta, horas, total })}>
        Confirmar reserva
      </button>
    </div>
  );
}

El total no es estado porque se deduce por completo de bicicleta.precioHora y horas. Guardarlo obligaría a recalcularlo en dos sitios (al cambiar las horas y al cambiar de bicicleta) y bastaría con olvidar uno para mostrar un precio falso. El tope inferior se aplica en App, el dueño del dato, no en el panel: así la regla vive junto al estado.

Solución 2.

// src/App.jsx (fragmento, justo debajo del SelectorTipo)
<p aria-live="polite" className="resultados">
  {bicicletasVisibles.length === 1
    ? `Mostrando 1 bicicleta de ${bicicletas.length}.`
    : `Mostrando ${bicicletasVisibles.length} bicicletas de ${bicicletas.length}.`}
</p>

Sin estado nuevo: ambos números salen de arrays que ya existen. aria-live="polite" hace que el lector de pantalla anuncie el cambio al terminar de leer lo que esté leyendo, de modo que quien filtra con el teclado sabe cuántos resultados quedan aunque no vea la lista.

Solución 3. El fallo: useState toma el modelo una única vez, en el primer render. Como al arrancar bicicletaSeleccionada es null, modelo vale 'Ninguna' para siempre; pulsa las cinco tarjetas y el texto no cambiará nunca. Ni siquiera un cambio posterior de bicicleta lo arregla, porque el estado ya está inicializado. Es el error clásico de copiar una prop en el estado.

// CORRECTO: sin estado; el dato ya lo tiene el padre
function ResumenSeleccion({ bicicletaSeleccionada }) {
  const modelo = bicicletaSeleccionada ? bicicletaSeleccionada.modelo : 'Ninguna';
  return <p>Bicicleta seleccionada: {modelo}</p>;
}

El componente pasa a ser una función pura de sus props: para las mismas props, siempre la misma salida. Imposible que se desincronice.

Conclusión

Elevar el estado es la técnica que convierte un conjunto de componentes sueltos en una aplicación. La regla cabe en una frase: el estado compartido vive en el ancestro común más cercano, baja como props y vuelve a subir en forma de avisos. Aplicándola, CicloUrbano ha saldado la deuda que arrastraba desde el módulo 2: SelectorTipo se ha convertido en un componente controlado sin estado propio, App guarda tipoElegido y bicicletaSeleccionada, bicicletasVisibles se calcula como valor derivado —nunca como estado— y PanelReserva recibe por fin una bicicleta real. El catálogo filtra de verdad.

También has visto los límites de la técnica. Elevar demasiado empeora el código: el estado realmente local —el texto de un buscador, si un panel está plegado— debe quedarse abajo. Y elevar lejos tiene un precio con nombre propio, la perforación de props, cuya respuesta llegará con useContext y el Módulo 7.

Queda una pregunta abierta que asoma en cuanto la interfaz crece: App ya empieza a acumular secciones, y pronto necesitarás envolver el catálogo en un diseño con cabecera y barra lateral, mostrar avisos de varios tipos y crear paneles reutilizables. En otros lenguajes, la respuesta sería la herencia: un PanelBase del que hereden PanelCatalogo y PanelReservas. En React esa respuesta es la equivocada, y la buena tiene otro nombre. La próxima lección es Composición vs Herencia, donde aprenderás a construir componentes a partir de otros componentes sin heredar ni una sola vez.

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