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
- El problema: dos hermanos y un dato compartido
- La regla: subir al ancestro común más cercano
- El refactor de CicloUrbano, paso a paso
Appcon estado:tipoElegidoybicicletaSeleccionada- Estado derivado:
bicicletasVisiblesno es estado - El flujo unidireccional, visto entero
- Una única fuente de verdad: el error de duplicar
- Componentes controlados y no controlados, a nivel de componente
- Qué NO conviene elevar
- El coste de elevar: la perforación de props
- 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.
- 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.
- 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
useStatey suimport. 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. alCambiarTipopasa 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-pressedcomunica 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:
Apptiene por fin estado, y son exactamente dos datos: qué filtro está activo y qué bicicleta está seleccionada. Ni uno más.ListaBicicletasrecibebicicletasVisibles, nobicicletas. El componente no se ha modificado: sigue recibiendo un array y pintándolo conmap. Ni siquiera sabe que existe un filtro, y esa ignorancia es una virtud, porque lo hace reutilizable en cualquier pantalla.PanelReservarecibe 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: conbicicletaSeleccionadaanullal arrancar, ese es literalmente el primer estado que ve la persona usuaria.manejarConfirmacionlimpia la selección poniéndola anull, 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.
App con estado: qué guardar y qué no
App con estado: qué guardar y qué noCuando 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.
- Estado derivado:
bicicletasVisibles no es estado
bicicletasVisibles no es estadoEste 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.
filtersobre 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, existeuseMemo(08-03). Medir primero, optimizar después.
- 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.
- 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:
useState(tipoElegido)solo lee la prop en el primer render. Es el valor inicial, no un vínculo permanente. Si mañanaAppañade un botón «Restablecer filtros» que hacesetTipoElegido('todos'), el padre dirá «todos» y el selector seguirá mostrando «Eléctricas» activo. Para siempre.- 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 |
- 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 conundefinedy no con un valor falsy, porqueabierto={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.
- 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.
- 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
Appconvierte 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>sinonChangequeda de solo lectura. - Consejo: mira las DevTools. Selecciona
Appen 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/alCambiarTipose lee comovalue/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
- ¿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
