Llevamos dos módulos arrastrando la misma fealdad: ListaBicicletas recibe tres props llamadas primera, segunda y tercera, y escribe tres tarjetas a mano. Con cinco bicicletas en el dominio.js ya se queda corta; con cincuenta sería insostenible. En esta lección se salda esa deuda y otra más antigua: en la lección 01-05 quedó dicho que las listas necesitan una identidad estable y que la mecánica completa llegaría aquí. Vas a aprender a transformar un array de datos en un array de elementos JSX con map, a entender qué es exactamente una key y qué le hace al algoritmo de reconciliación, a reconocer por qué el índice del array es casi siempre una mala clave —con un ejemplo que puedes reproducir y ver fallar—, y a combinar filter, sort y map sin destrozar los datos originales.
Contenido
- El problema: repetición escrita a mano
map: de un array de datos a un array de elementos- Devolver JSX desde
map: elreturnque se olvida - El refactor:
ListaBicicletasconmap - Qué es
keyy por qué React la necesita - Qué sirve como clave y qué no
- El índice del array: el error reproducible
- Claves en fragmentos
filterysortsin mutar el array original- Listas anidadas: estaciones con sus bicicletas
- El estado vacío de una lista
- El problema: repetición escrita a mano
Este es el punto de partida, tal como quedó al final de la lección anterior:
// src/componentes/ListaBicicletas.jsx — VERSIÓN A SUSTITUIR
function ListaBicicletas({ primera, segunda, tercera, alSeleccionar, alReservar }) {
return (
<section className={estilos.lista}>
<h2>Bicicletas del catálogo</h2>
{primera && <TarjetaBicicleta bicicleta={primera} nombreEstacion="Plaza Mayor" … />}
{segunda && <TarjetaBicicleta bicicleta={segunda} nombreEstacion="Plaza Mayor" … />}
{tercera && <TarjetaBicicleta bicicleta={tercera} nombreEstacion="Parque Norte" … />}
</section>
);
}Los defectos son estructurales, no de estilo:
| Defecto | Consecuencia |
|---|---|
| El número de elementos está fijado en el código | Las bicicletas 4 y 5 del dominio.js no se ven. Añadir una exige tocar dos ficheros |
| Los nombres de las props no significan nada | primera no describe un dato: describe una posición |
| Repetición literal | Cambiar una prop de la tarjeta obliga a repetir el cambio tres veces, con el riesgo de olvidar una |
| Imposible filtrar u ordenar | Cualquier filtro tendría que reasignar manualmente qué bicicleta va en cada hueco |
| El estado vacío es artificial | Hay que contar props en lugar de contar datos |
Y el problema de fondo: el número de tarjetas es un dato, no una decisión del programador. Debe salir del array.
map: de un array de datos a un array de elementos
map: de un array de datos a un array de elementosArray.prototype.map es JavaScript estándar, no una función de React: recorre un array y devuelve otro array nuevo con el resultado de aplicar una función a cada elemento. El array original no se toca.
const precios = [2.5, 4.0, 5.5];
const conIva = precios.map((precio) => precio * 1.21);
// precios sigue siendo [2.5, 4, 5.5]
// conIva es [3.025, 4.84, 6.655]La idea de React es exactamente la misma, cambiando números por elementos JSX:
const modelos = ['Urbana Clásica', 'Eléctrica Pro', 'Carga Max'];
const elementos = modelos.map((modelo) => <li>{modelo}</li>);
// elementos es un array de tres objetos elemento de ReactY aquí entra en juego una propiedad del JSX que quizá no esperabas: React sabe pintar un array de elementos. Si pones un array dentro de unas llaves, React lo recorre y pinta cada elemento en orden, sin separadores ni comas.
function ListaModelos() {
const modelos = ['Urbana Clásica', 'Eléctrica Pro', 'Carga Max'];
return (
<ul>
{modelos.map((modelo) => (
<li key={modelo}>{modelo}</li>
))}
</ul>
);
}El flujo mental, en tres pasos:
flowchart LR
A["Array de DATOS<br/>['Urbana Clásica', …]"] -- ".map(…)" --> B["Array de ELEMENTOS<br/>[<li>, <li>, <li>]"]
B -- "{ } dentro del JSX" --> C["React pinta cada uno<br/>en orden"]
Fíjate en el key={modelo}: es obligatorio y lo explicaremos en el apartado 5. De momento acostúmbrate a escribirlo siempre que uses map para generar elementos.
- Devolver JSX desde
map: el return que se olvida
map: el return que se olvidaLa función que pasas a map tiene que devolver algo. Con funciones flecha hay dos formas de escribirla, y confundirlas produce un fallo desconcertante: la lista se queda vacía sin ningún error.
{/* 1. Cuerpo de expresión con paréntesis: el return es IMPLÍCITO */}
{bicicletas.map((bicicleta) => (
<TarjetaBicicleta key={bicicleta.id} bicicleta={bicicleta} />
))}
{/* 2. Cuerpo de bloque con llaves: el return es OBLIGATORIO */}
{bicicletas.map((bicicleta) => {
const precio = bicicleta.precioHora.toFixed(2);
return <TarjetaBicicleta key={bicicleta.id} bicicleta={bicicleta} precio={precio} />;
})}
{/* 3. EL ERROR: llaves de bloque SIN return */}
{bicicletas.map((bicicleta) => {
<TarjetaBicicleta key={bicicleta.id} bicicleta={bicicleta} />; // ✘ no devuelve nada
})}En el tercer caso, la función devuelve undefined para cada elemento, así que map produce [undefined, undefined, undefined], y React no pinta los undefined. Resultado: una sección vacía, sin errores ni avisos. Es un fallo clásico que puede costar veinte minutos.
| Sintaxis | Cuándo usarla | Cuidado |
|---|---|---|
(x) => ( … ) |
El caso normal: solo devuelves JSX | Los paréntesis no son obligatorios, pero evitan errores de formato |
(x) => { … return … } |
Cuando necesitas calcular variables antes | Nunca olvides el return |
(x) => { … } sin return |
Nunca para renderizar | Devuelve undefined en silencio |
Regla mnemotécnica: paréntesis devuelven, llaves ejecutan.
- El refactor:
ListaBicicletas con map
ListaBicicletas con mapEste es el cambio más importante del módulo. Compara el antes y el después.
Antes
// src/componentes/ListaBicicletas.jsx — ANTES
import TarjetaBicicleta from './TarjetaBicicleta.jsx';
/**
* Props: primera, segunda, tercera (objetos bicicleta)
*/
function ListaBicicletas({ primera, segunda, tercera, alSeleccionar, alReservar }) {
return (
<section className="lista-bicicletas">
<h2>Bicicletas del catálogo</h2>
{primera && (
<TarjetaBicicleta bicicleta={primera} nombreEstacion="Plaza Mayor"
alSeleccionar={alSeleccionar} alReservar={alReservar} />
)}
{segunda && (
<TarjetaBicicleta bicicleta={segunda} nombreEstacion="Plaza Mayor"
alSeleccionar={alSeleccionar} alReservar={alReservar} />
)}
{tercera && (
<TarjetaBicicleta bicicleta={tercera} nombreEstacion="Parque Norte"
alSeleccionar={alSeleccionar} alReservar={alReservar} />
)}
</section>
);
}
export default ListaBicicletas;Después
// src/componentes/ListaBicicletas.jsx — DESPUÉS
import TarjetaBicicleta from './TarjetaBicicleta.jsx';
import estilos from './ListaBicicletas.module.css';
/**
* Sección del catálogo de CicloUrbano.
* Props:
* - bicicletas (array de objetos bicicleta, opcional, por defecto [])
* - estaciones (array de objetos estación, opcional, por defecto [])
* - alSeleccionar (función, opcional): recibe la bicicleta pulsada
* - alReservar (función, opcional): recibe la bicicleta a reservar
*/
function ListaBicicletas({ bicicletas = [], estaciones = [], alSeleccionar, alReservar }) {
// Índice auxiliar: id de estación -> nombre, para no recorrer el array en cada tarjeta
const nombrePorEstacion = {};
estaciones.forEach((estacion) => {
nombrePorEstacion[estacion.id] = estacion.nombre;
});
if (bicicletas.length === 0) {
return (
<section className={estilos.lista}>
<h2>Bicicletas del catálogo</h2>
<p className={estilos.vacio}>
No hay bicicletas que coincidan con el filtro. Prueba con otro tipo.
</p>
</section>
);
}
return (
<section className={estilos.lista}>
<h2>Bicicletas del catálogo</h2>
<p className={estilos.recuento}>
{bicicletas.length === 1
? 'Se ha encontrado 1 bicicleta.'
: `Se han encontrado ${bicicletas.length} bicicletas.`}
</p>
{bicicletas.map((bicicleta) => (
<TarjetaBicicleta
key={bicicleta.id}
bicicleta={bicicleta}
nombreEstacion={nombrePorEstacion[bicicleta.estacionId] ?? 'Estación desconocida'}
alSeleccionar={alSeleccionar}
alReservar={alReservar}
/>
))}
</section>
);
}
export default ListaBicicletas;Y en App.jsx desaparecen las tres props numeradas:
// src/App.jsx (fragmento)
import { bicicletas, estaciones } from './datos/dominio.js';
<ListaBicicletas
bicicletas={bicicletas}
estaciones={estaciones}
alSeleccionar={manejarSeleccionBicicleta}
alReservar={manejarReserva}
/>Lo que ha cambiado, punto por punto:
- Una prop en lugar de tres, y con un nombre que describe qué es el dato, no dónde está.
- Las cinco bicicletas se ven, no solo las tres primeras. Y si mañana entran veinte, no hay que tocar nada.
nombreEstacionya no está escrito a mano. Antes decía «Plaza Mayor» para las dos primeras porque casualmente era cierto; ahora se resuelve a partir debicicleta.estacionId, que es el dato real. Con el índicenombrePorEstacion,bici-003muestra correctamente «Parque Norte» ybici-004, «Estación Central».- El estado vacío se comprueba con
bicicletas.length === 0mediante un retorno anticipado, como aprendiste en 03-02. key={bicicleta.id}: cada tarjeta lleva la identidad estable que React necesita. Vamos a ello.
- Qué es
key y por qué React la necesita
key y por qué React la necesitaEn la lección 01-05 viste que React compara los dos árboles por posición, nivel a nivel, y que ese criterio funciona bien salvo en un caso: las listas que cambian. Ahí es donde entra key.
Una clave es una cadena o número que le dice a React: «este elemento de la lista es este dato concreto, esté donde esté ahora». React no la usa para nada visual —no llega al DOM, no la puedes leer desde el componente— sino exclusivamente para emparejar elementos entre dos renders.
El caso que lo demuestra: insertar al principio
Supón que el catálogo muestra tres bicicletas y llega una nueva por delante.
// Render anterior: [bici-001, bici-002, bici-003]
// Render nuevo: [bici-005, bici-001, bici-002, bici-003]Sin claves estables (comparando por posición), React razona así:
flowchart TD
subgraph SIN["SIN claves estables: compara por posición"]
direction TB
A0["pos 0: bici-001 → bici-005<br/>ACTUALIZA todo el contenido"]
A1["pos 1: bici-002 → bici-001<br/>ACTUALIZA todo el contenido"]
A2["pos 2: bici-003 → bici-002<br/>ACTUALIZA todo el contenido"]
A3["pos 3: (nada) → bici-003<br/>CREA un nodo nuevo"]
end
Cuatro operaciones sobre el DOM, y las tres primeras reescriben tarjetas que no habían cambiado.
Con key={bicicleta.id}, React compara por identidad:
flowchart TD
subgraph CON["CON key={bicicleta.id}: compara por identidad"]
direction TB
B0["bici-001 ya existía → REUTILIZA, solo se mueve"]
B1["bici-002 ya existía → REUTILIZA, solo se mueve"]
B2["bici-003 ya existía → REUTILIZA, solo se mueve"]
B3["bici-005 es nueva → CREA e INSERTA al principio"]
end
Una sola creación y ninguna reescritura. Pero el beneficio de verdad no es el rendimiento: es la corrección. Cada tarjeta conserva su estado interno —un desplegable abierto, un campo de horas a medio rellenar, una marca de favorito— porque React sabe que sigue siendo la misma tarjeta.
Las reglas de key
| Regla | Explicación |
|---|---|
| Única entre hermanos | Dos elementos de la misma lista no pueden compartir clave. En otra lista distinta sí se puede repetir |
| Estable entre renders | El mismo dato debe tener siempre la misma clave. Nada de Math.random() ni Date.now() |
Va en el elemento más externo del map |
Si envuelves la tarjeta en un <li>, la clave va en el <li>, no en la tarjeta |
| No es una prop | key la consume React. Dentro del componente, props.key es undefined. Si necesitas el id, pásalo aparte |
Ese último punto sorprende a mucha gente:
{/* Si el componente necesita el id, hay que pasarlo dos veces */}
<TarjetaBicicleta key={bicicleta.id} bicicleta={bicicleta} />
{/* Dentro, se lee como bicicleta.id — nunca como props.key */}Si olvidas la clave, React no rompe nada, pero deja un aviso muy visible en la consola: «Warning: Each child in a list should have a unique "key" prop». No lo ignores: significa que tu lista está comparando por posición.
- Qué sirve como clave y qué no
| Fuente de la clave | ¿Sirve? | Por qué |
|---|---|---|
bicicleta.id (id del dominio) |
Sí, la mejor | Único, estable y ya existe en los datos |
| Un identificador de base de datos | Sí | Mismo motivo |
| Un campo único de negocio (email, matrícula) | Sí, con cuidado | Debe ser realmente único e inmutable |
Una combinación de campos: `${estacionId}-${bicicletaId}` |
Sí | Útil cuando no hay un id único propio |
Un identificador generado al crear el dato (crypto.randomUUID()) |
Sí | Se genera una vez y se guarda en el dato, no en el render |
bicicleta.modelo |
Casi nunca | Se repite: bici-001 y bici-004 son ambas «Urbana Clásica» |
| El índice del array | Casi nunca | Es la posición, no la identidad. Ver apartado 7 |
Math.random() |
Nunca | Cambia en cada render: React destruye y recrea la lista entera cada vez |
Date.now() |
Nunca | Mismo problema |
| Un contador que incrementas en el render | Nunca | Es el índice disfrazado, y además muta durante el render |
Sobre el índice hay un matiz honesto: es aceptable si se cumplen las tres condiciones a la vez.
- La lista nunca se reordena ni se filtra.
- Los elementos nunca se insertan ni se eliminan por el medio o el principio (solo se añaden al final, si acaso).
- Los elementos no tienen estado interno ni contienen campos de formulario.
El SelectorTipo de 02-04 usaba key={tipo} sobre una constante TIPOS que jamás cambia: ahí incluso el índice habría sido inocuo. Pero como casi ninguna lista real cumple las tres condiciones para siempre, la recomendación práctica es: si el dato tiene id, usa el id; si no lo tiene, dáselo.
- El índice del array: el error reproducible
La teoría no convence tanto como ver el fallo. Vamos a construirlo.
// src/componentes/FilaFavorita.jsx
import { useState } from 'react';
/**
* Fila de una bicicleta con una marca de favorito en ESTADO LOCAL.
* Props:
* - bicicleta (objeto, obligatorio)
* - alEliminar (función, obligatoria): recibe el id de la bicicleta
*/
function FilaFavorita({ bicicleta, alEliminar }) {
const [favorita, setFavorita] = useState(false);
return (
<li>
<button type="button" onClick={() => setFavorita(!favorita)}>
{favorita ? '★' : '☆'}
</button>{' '}
{bicicleta.modelo} ({bicicleta.id}){' '}
<button type="button" onClick={() => alEliminar(bicicleta.id)}>
Eliminar
</button>
</li>
);
}
export default FilaFavorita;Y el componente que la usa, con la clave mal puesta:
// src/componentes/ListaFavoritas.jsx — VERSIÓN CON EL FALLO
import { useState } from 'react';
import { bicicletas as bicicletasIniciales } from '../datos/dominio.js';
import FilaFavorita from './FilaFavorita.jsx';
function ListaFavoritas() {
const [lista, setLista] = useState(bicicletasIniciales);
function manejarEliminar(id) {
setLista(lista.filter((bicicleta) => bicicleta.id !== id));
}
return (
<ul>
{lista.map((bicicleta, indice) => (
// ✘ EL FALLO: la clave es la posición, no la identidad
<FilaFavorita key={indice} bicicleta={bicicleta} alEliminar={manejarEliminar} />
))}
</ul>
);
}
export default ListaFavoritas;Cómo reproducir el fallo, paso a paso
- Marca como favorita solo
bici-003(Carga Max), la tercera de la lista. Su estrella se pone en ★. - Pulsa «Eliminar» en
bici-001(la primera). - Observa la lista resultante.
Lo que esperas: bici-002, bici-003 (★), bici-004, bici-005.
Lo que ocurre: bici-002, bici-003, bici-004 (★), bici-005. La estrella se ha movido a la bicicleta equivocada.
¿Por qué? Porque el estado favorita vive en el componente, y React asocia cada componente a su clave. La bicicleta favorita estaba en la posición 2, así que su estado quedó asociado a la clave 2. Al eliminar la primera, todo se desplaza y ahora en la posición 2 está bici-004… que hereda el estado de quien ocupaba esa clave antes.
| Antes de eliminar | Después de eliminar (clave = índice) |
|---|---|
clave 0 → bici-001, favorita: no |
clave 0 → bici-002, favorita: no |
clave 1 → bici-002, favorita: no |
clave 1 → bici-003, favorita: no ✘ |
clave 2 → bici-003, favorita: sí |
clave 2 → bici-004, favorita: sí ✘ |
clave 3 → bici-004, favorita: no |
clave 3 → bici-005, favorita: no |
clave 4 → bici-005, favorita: no |
— |
El estado no se movió: se quedó pegado a la posición, y los datos se deslizaron por debajo.
La corrección
{lista.map((bicicleta) => (
<FilaFavorita key={bicicleta.id} bicicleta={bicicleta} alEliminar={manejarEliminar} />
))}Repite el experimento: la estrella permanece en bici-003 pase lo que pase. Con la clave correcta, React entiende que bici-001 ha desaparecido y que las demás son las mismas de siempre, así que conserva su estado y solo elimina un nodo del DOM.
Este mismo mecanismo explica un fallo aún más frecuente en aplicaciones reales: un campo de texto a medio rellenar que salta a otra fila al ordenar o filtrar la lista. La causa es idéntica.
- Claves en fragmentos
A veces cada elemento de la lista necesita producir varios nodos hermanos sin un contenedor que los envuelva. El caso típico es una lista de definición o una tabla:
{estaciones.map((estacion) => (
<>
<dt>{estacion.nombre}</dt>
<dd>{estacion.barrio} · {estacion.plazas} plazas</dd>
</>
))}Esto no funciona: la sintaxis corta <>…</> no admite atributos, así que no hay dónde poner la clave y React avisa. La solución es usar la forma larga del fragmento, importándolo desde React:
import { Fragment } from 'react';
function ListaDefinicionEstaciones({ estaciones }) {
return (
<dl>
{estaciones.map((estacion) => (
<Fragment key={estacion.id}>
<dt>{estacion.nombre}</dt>
<dd>
{estacion.barrio} · {estacion.plazas} plazas
</dd>
</Fragment>
))}
</dl>
);
}
export default ListaDefinicionEstaciones;| Forma | ¿Admite key? |
Cuándo usarla |
|---|---|---|
<>…</> |
No | Agrupar elementos fuera de una lista |
<Fragment key={…}>…</Fragment> |
Sí | Cada iteración produce varios hermanos |
<div key={…}>…</div> |
Sí | Solo si el contenedor tiene sentido semántico o de estilo |
La ventaja del fragmento frente al <div> es que no añade un nodo al DOM. En una <dl>, una <table> o un contenedor con display: grid, meter un <div> intermedio rompería la semántica o la maquetación.
filter y sort sin mutar el array original
filter y sort sin mutar el array originalEn cuanto una lista se pinta con map, lo natural es querer filtrarla y ordenarla. Aquí hay una trampa de JavaScript que muerde con fuerza en React.
filter es seguro; sort no
const disponibles = bicicletas.filter((b) => b.estado === 'disponible');
// bicicletas NO se ha modificado: filter devuelve un array nuevo
bicicletas.sort((a, b) => a.precioHora - b.precioHora);
// ✘ bicicletas SÍ se ha modificado: sort ordena EN EL SITIORecuerda la regla de inmutabilidad de la lección 02-04: nunca modifiques directamente unos datos que vienen de props, del estado o de un módulo importado. Si haces sort sobre el array de dominio.js, lo reordenas para toda la aplicación, sin que React se entere y sin poder volver atrás.
Las tres formas correctas de ordenar:
// 1. toSorted: método moderno que devuelve un array NUEVO ordenado (ES2023)
const porPrecio = bicicletas.toSorted((a, b) => a.precioHora - b.precioHora);
// 2. Copia previa con spread y luego sort sobre la copia
const porPrecio = [...bicicletas].sort((a, b) => a.precioHora - b.precioHora);
// 3. Copia con slice(), equivalente y compatible con navegadores muy antiguos
const porPrecio = bicicletas.slice().sort((a, b) => a.precioHora - b.precioHora);toSorted, toReversed, toSpliced y with son la familia de métodos no destructivos añadida a JavaScript en 2023. Están disponibles en todos los navegadores actuales y son la opción más legible. Si tu proyecto debe soportar entornos antiguos, la copia con spread hace exactamente lo mismo.
| Método | ¿Muta el original? | Alternativa segura |
|---|---|---|
map |
No | — |
filter |
No | — |
slice |
No | — |
concat |
No | — |
sort |
Sí | toSorted o [...array].sort() |
reverse |
Sí | toReversed o [...array].reverse() |
splice |
Sí | toSpliced o filter |
push / pop / shift |
Sí | Spread: [...array, nuevo] |
La cadena completa: filtrar, ordenar y pintar
// src/componentes/CatalogoOrdenado.jsx
import TarjetaBicicleta from './TarjetaBicicleta.jsx';
/**
* Catálogo filtrado por tipo y ordenado por precio ascendente.
* Props:
* - bicicletas (array, obligatorio)
* - tipo (cadena, opcional, por defecto 'todos')
*/
function CatalogoOrdenado({ bicicletas, tipo = 'todos' }) {
// Toda la cadena produce arrays nuevos: el original queda intacto
const visibles = bicicletas
.filter((bicicleta) => tipo === 'todos' || bicicleta.tipo === tipo)
.toSorted((a, b) => a.precioHora - b.precioHora);
if (visibles.length === 0) {
return <p>No hay bicicletas de tipo «{tipo}».</p>;
}
return (
<section>
{visibles.map((bicicleta) => (
<TarjetaBicicleta key={bicicleta.id} bicicleta={bicicleta} />
))}
</section>
);
}
export default CatalogoOrdenado;Tres observaciones importantes:
visibleses un valor derivado, no estado. Se recalcula en cada render a partir de las props, y por eso nunca puede desincronizarse. Es exactamente la lección de 02-04 aplicada a listas.- La cadena se lee de arriba abajo: filtrar y luego ordenar. Filtrar primero es además más eficiente, porque se ordenan menos elementos.
- Las claves siguen siendo los
id. Aunque el orden cambie con cada criterio, cada tarjeta conserva su identidad y su estado interno.
Este componente es el ensayo general de lo que ocurrirá en Elevando el Estado, cuando el tipo deje de ser una prop fija y venga del SelectorTipo.
- Listas anidadas: estaciones con sus bicicletas
Las listas dentro de listas son habitualísimas, y solo tienen una regla nueva: cada map necesita sus propias claves, únicas entre sus hermanos. No hay ningún requisito de unicidad global.
// src/componentes/EstacionesConFlota.jsx
import EtiquetaEstado from './EtiquetaEstado.jsx';
import estilos from './EstacionesConFlota.module.css';
/**
* Listado de estaciones de CicloUrbano con las bicicletas aparcadas en cada una.
* Props:
* - estaciones (array, obligatorio)
* - bicicletas (array, obligatorio)
*/
function EstacionesConFlota({ estaciones, bicicletas }) {
return (
<section className={estilos.contenedor}>
<h2>Estaciones</h2>
{estaciones.map((estacion) => {
// Lista derivada: las bicicletas de ESTA estación
const enEstacion = bicicletas.filter((b) => b.estacionId === estacion.id);
const libres = estacion.plazas - enEstacion.length;
return (
<article key={estacion.id} className={estilos.estacion}>
<h3>
{estacion.nombre} <small>({estacion.barrio})</small>
</h3>
<p>
{enEstacion.length} de {estacion.plazas} plazas ocupadas · {libres} libres
</p>
{enEstacion.length > 0 ? (
<ul className={estilos.flota}>
{enEstacion.map((bicicleta) => (
<li key={bicicleta.id}>
{bicicleta.modelo} <EtiquetaEstado estado={bicicleta.estado} />
</li>
))}
</ul>
) : (
<p className={estilos.vacio}>No hay bicicletas aparcadas aquí.</p>
)}
</article>
);
})}
</section>
);
}
export default EstacionesConFlota;Detalles que merecen atención:
- El
mapexterior usa cuerpo de bloque conreturn, porque necesita calcularenEstacionylibresantes de devolver el JSX. Es el caso 2 del apartado 3: si olvidas esereturn, no se pinta ninguna estación. - Dos niveles de claves independientes:
key={estacion.id}en las estaciones ykey={bicicleta.id}en las bicicletas de cada una. Quebici-001yest-01no se parezcan es irrelevante; solo importa la unicidad entre hermanos. enEstacionse calcula dentro delmap, así que cada estación tiene su propia lista derivada, sin estado adicional.
Con los datos del dominio.js, el resultado es:
| Estación | Plazas | Bicicletas aparcadas |
|---|---|---|
est-01 Plaza Mayor (Centro) |
20 | bici-001 Urbana Clásica, bici-002 Eléctrica Pro |
est-02 Parque Norte (Norte) |
15 | bici-003 Carga Max, bici-005 Eléctrica Pro |
est-03 Estación Central (Ensanche) |
30 | bici-004 Urbana Clásica |
flowchart TD
A["EstacionesConFlota"] --> B["map sobre estaciones<br/>key = estacion.id"]
B --> C1["est-01 Plaza Mayor"]
B --> C2["est-02 Parque Norte"]
B --> C3["est-03 Estación Central"]
C1 --> D1["map sobre sus bicicletas<br/>key = bicicleta.id"]
C2 --> D2["map sobre sus bicicletas<br/>key = bicicleta.id"]
C3 --> D3["map sobre sus bicicletas<br/>key = bicicleta.id"]
- El estado vacío de una lista
Ya lo has usado en los ejemplos, pero conviene fijarlo como patrón porque es de los que distinguen una interfaz cuidada de una descuidada. Una lista puede estar vacía por motivos muy distintos, y el mensaje debería decir cuál:
| Situación | Mensaje adecuado |
|---|---|
| No hay datos en absoluto | «Todavía no hay bicicletas registradas.» |
| Hay datos, pero el filtro no encuentra nada | «Ninguna bicicleta coincide con el filtro. Prueba con otro tipo.» |
| Los datos aún no han llegado | «Cargando catálogo…» |
| Ha fallado la carga | «No se ha podido cargar el catálogo. Inténtalo de nuevo.» |
function Catalogo({ bicicletas, tipoFiltro }) {
const visibles = bicicletas.filter(
(b) => tipoFiltro === 'todos' || b.tipo === tipoFiltro
);
if (bicicletas.length === 0) {
return <p className="vacio">Todavía no hay bicicletas registradas.</p>;
}
if (visibles.length === 0) {
return (
<p className="vacio">
Ninguna bicicleta coincide con el filtro «{tipoFiltro}». Prueba con otro tipo.
</p>
);
}
return (
<section>
{visibles.map((bicicleta) => (
<TarjetaBicicleta key={bicicleta.id} bicicleta={bicicleta} />
))}
</section>
);
}Distinguir «no hay nada» de «no hay nada que coincida» cuesta tres líneas y evita que alguien piense que la aplicación está rota. Los casos de carga y error se tratarán cuando lleguen los datos asíncronos, en Hook useEffect y Límites de Error.
Errores Comunes y Consejos
- Olvidar el
returndentro de unmapcon llaves. La lista sale vacía y no hay ningún error. Recuerda: paréntesis devuelven, llaves ejecutan. - Usar
forEachen lugar demap.forEachsiempre devuelveundefined: no produce nada que pintar. Para generar elementos,map. - Omitir la
key. React avisa por consola y tu lista pasa a compararse por posición, con todos los problemas del apartado 7. - Usar el índice como clave en una lista que se filtra, ordena o elimina. El estado interno se queda pegado a la posición y acaba en la fila equivocada.
- Usar
Math.random()como clave. Cada render genera claves nuevas, así que React destruye y recrea toda la lista: pierdes el estado, el foco y el rendimiento. - Poner la
keyen el elemento equivocado. Debe ir en el nodo más externo que devuelve elmap, no en un hijo suyo. - Intentar leer
props.keydentro del componente. No existe. Si necesitas el identificador, pásalo también como prop normal. - Repetir claves entre hermanos. Con dos elementos con la misma clave, React avisa y el comportamiento pasa a ser impredecible. Ojo con usar
modelocomo clave:bici-001ybici-004comparten modelo. - Usar
<>…</>cuando hace falta una clave. La sintaxis corta no admite atributos; usa<Fragment key={…}>. - Ordenar con
sortsobre las props o el estado.sortmuta el array. UsatoSortedo una copia previa. - Consejo: si tus datos no tienen id, genéralo al crearlos, no al renderizarlos.
crypto.randomUUID()en el momento de añadir el elemento, guardado dentro del objeto. - Consejo: calcula la lista visible como valor derivado, encadenando
filterytoSortedjusto antes delreturn. No la guardes en estado: se desincronizaría. - Consejo: extrae el contenido de la iteración a un componente propio en cuanto pase de cinco o seis líneas.
{bicicletas.map(b => <TarjetaBicicleta key={b.id} bicicleta={b} />)}es una línea que se entiende sola.
Ejercicios
Ejercicio 1
Este componente tiene cuatro errores relacionados con listas y claves. Identifícalos, explica el síntoma de cada uno y escribe la versión corregida.
function TablaBicicletas({ bicicletas }) {
const ordenadas = bicicletas.sort((a, b) => a.precioHora - b.precioHora);
return (
<table>
<tbody>
{ordenadas.map((bicicleta, i) => {
const precio = bicicleta.precioHora.toFixed(2);
<tr key={i}>
<td>{bicicleta.modelo}</td>
<td>{precio} €</td>
</tr>;
})}
</tbody>
</table>
);
}Ejercicio 2
Crea el componente ResumenPorTipo (src/componentes/ResumenPorTipo.jsx) que reciba el array bicicletas y muestre una tabla con una fila por cada tipo (urbana, electrica, carga), indicando cuántas bicicletas hay de ese tipo, cuántas están disponibles y el precio medio por hora formateado a la española.
Requisitos:
- Reutiliza la constante
TIPOSsin el valor'todos'. - No mutes el array recibido.
- Si un tipo no tiene ninguna bicicleta, la fila debe mostrar «—» en el precio medio en lugar de
NaN. - Usa claves adecuadas.
Ejercicio 3
Partiendo del componente ListaFavoritas del apartado 7, amplíalo para que además permita mover una bicicleta al principio de la lista con un botón «Subir al principio». Después:
- Ejecútalo con
key={indice}, marca como favoritabici-005(la última) y súbela al principio. Describe qué ocurre con la estrella y explica por qué. - Cámbialo a
key={bicicleta.id}y repite. Explica la diferencia en términos de reconciliación.
Soluciones
Solución 1.
Los cuatro errores:
| Error | Síntoma |
|---|---|
bicicletas.sort(...) sin copiar |
Muta el array recibido por props, reordenándolo para toda la aplicación sin avisar a React |
El map usa llaves sin return |
La devolución es undefined para cada fila: la tabla sale completamente vacía, sin errores |
key={i} |
El índice como clave: si la tabla se reordena por otro criterio, cualquier estado interno se desplaza de fila |
toFixed(2) sin formato español |
Muestra 2.50 en lugar de 2,50. No es un error funcional, pero rompe la convención del proyecto |
// src/componentes/TablaBicicletas.jsx
/**
* Tabla de bicicletas ordenada por precio ascendente.
* Props:
* - bicicletas (array, opcional, por defecto [])
*/
function TablaBicicletas({ bicicletas = [] }) {
// toSorted devuelve un array nuevo: el original no se toca
const ordenadas = bicicletas.toSorted((a, b) => a.precioHora - b.precioHora);
if (ordenadas.length === 0) {
return <p>No hay bicicletas que mostrar.</p>;
}
return (
<table>
<thead>
<tr>
<th>Modelo</th>
<th>Precio por hora</th>
</tr>
</thead>
<tbody>
{ordenadas.map((bicicleta) => {
const precio = bicicleta.precioHora.toFixed(2).replace('.', ',');
return (
<tr key={bicicleta.id}>
<td>{bicicleta.modelo}</td>
<td>{precio} €</td>
</tr>
);
})}
</tbody>
</table>
);
}
export default TablaBicicletas;Solución 2.
// src/componentes/ResumenPorTipo.jsx
const TIPOS_BICICLETA = ['urbana', 'electrica', 'carga'];
const NOMBRES_TIPO = {
urbana: 'Urbanas',
electrica: 'Eléctricas',
carga: 'De carga'
};
/**
* Resumen de la flota agrupado por tipo de bicicleta.
* Props:
* - bicicletas (array, opcional, por defecto [])
*
* Sin estado: todas las cifras son valores derivados de la prop.
*/
function ResumenPorTipo({ bicicletas = [] }) {
return (
<table className="resumen-por-tipo">
<thead>
<tr>
<th>Tipo</th>
<th>Total</th>
<th>Disponibles</th>
<th>Precio medio</th>
</tr>
</thead>
<tbody>
{TIPOS_BICICLETA.map((tipo) => {
// filter no muta: cada llamada devuelve un array nuevo
const delTipo = bicicletas.filter((bicicleta) => bicicleta.tipo === tipo);
const disponibles = delTipo.filter((b) => b.estado === 'disponible').length;
const suma = delTipo.reduce((total, b) => total + b.precioHora, 0);
const media =
delTipo.length > 0
? (suma / delTipo.length).toFixed(2).replace('.', ',') + ' €'
: '—';
return (
<tr key={tipo}>
<td>{NOMBRES_TIPO[tipo]}</td>
<td>{delTipo.length}</td>
<td>{disponibles}</td>
<td>{media}</td>
</tr>
);
})}
</tbody>
</table>
);
}
export default ResumenPorTipo;Con los datos del dominio.js el resultado es:
| Tipo | Total | Disponibles | Precio medio |
|---|---|---|---|
| Urbanas | 2 | 2 | 2,50 € |
| Eléctricas | 2 | 1 | 4,00 € |
| De carga | 1 | 0 | 5,50 € |
La clave es tipo porque los tipos son únicos entre sí y estables: el array TIPOS_BICICLETA nunca se reordena. El if del precio medio evita el NaN que produciría dividir entre cero.
Solución 3.
El añadido al componente:
function manejarSubir(id) {
const elegida = lista.find((bicicleta) => bicicleta.id === id);
const resto = lista.filter((bicicleta) => bicicleta.id !== id);
setLista([elegida, ...resto]); // array NUEVO: nunca se muta el anterior
}1. Con key={indice}. Marcas bici-005 (posición 4) como favorita: el estado favorita: true queda asociado a la clave 4. Al subirla al principio, bici-005 pasa a la clave 0, y la clave 4 la ocupa ahora bici-004. React ve que en la clave 0 sigue habiendo un FilaFavorita del mismo tipo, así que reutiliza el componente y su estado, y solo actualiza las props. Resultado: la estrella se queda en la posición 4, es decir, sobre bici-004, mientras que bici-005, ya al principio, aparece sin marcar. El estado ha viajado a la bicicleta equivocada.
2. Con key={bicicleta.id}. React empareja los elementos por identidad, no por posición: reconoce que bici-005 sigue existiendo —simplemente se ha movido— y mueve su nodo del DOM junto con su estado, sin recrearlo. Los demás componentes no se tocan siquiera. Resultado: la estrella acompaña a bici-005 hasta el principio de la lista, que es lo que cualquiera esperaría.
La diferencia, en términos de reconciliación: con el índice, React empareja posición con posición, así que un movimiento se interpreta como «todos estos elementos han cambiado de contenido». Con el id, empareja identidad con identidad, así que un movimiento se interpreta como lo que es: un movimiento.
Conclusión
Con esta lección se cierran las dos deudas que arrastraba el curso. La primera era práctica: ListaBicicletas ya no recibe primera, segunda y tercera ni escribe las tarjetas a mano, sino que recibe el array completo y lo recorre con map, resolviendo además el nombre de cada estación a partir de estacionId en lugar de tenerlo escrito a mano. Las cinco bicicletas del dominio.js se ven, y si mañana hay cincuenta no habrá que tocar ni una línea. La segunda deuda venía de la lección 01-05: ahora sabes qué hace exactamente una key —emparejar elementos entre renders por identidad en lugar de por posición— y has visto el fallo que evita, con una marca de favorito que se queda en la fila equivocada al eliminar un elemento.
Por el camino has aprendido la mecánica completa: que map transforma un array de datos en un array de elementos y que React sabe pintar arrays; que las llaves de bloque exigen return y los paréntesis no; que la clave debe ser única entre hermanos y estable entre renders, que el id del dominio es casi siempre la respuesta correcta y que el índice, Math.random() y los campos repetidos no lo son; que los fragmentos con clave se escriben <Fragment key={…}> porque la sintaxis corta no admite atributos; que filter es seguro y sort muta, por lo que hay que usar toSorted o una copia previa; y que las listas anidadas solo requieren claves únicas entre hermanos, no globalmente.
El catálogo de CicloUrbano está completo como escaparate interactivo: se pinta desde los datos, reacciona a los clics, se adapta al estado de cada bicicleta y sabe qué decir cuando no hay nada que mostrar. Lo que todavía no puede hacer la aplicación es recoger información. Una reserva necesita elegir bicicleta, indicar una fecha, unas horas y aceptar las condiciones; y para eso hacen falta campos de formulario cuyo valor esté bajo el control de React, no del DOM. Ese es el siguiente paso: Formularios y Componentes Controlados.
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
