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

  1. El problema: repetición escrita a mano
  2. map: de un array de datos a un array de elementos
  3. Devolver JSX desde map: el return que se olvida
  4. El refactor: ListaBicicletas con map
  5. Qué es key y por qué React la necesita
  6. Qué sirve como clave y qué no
  7. El índice del array: el error reproducible
  8. Claves en fragmentos
  9. filter y sort sin mutar el array original
  10. Listas anidadas: estaciones con sus bicicletas
  11. El estado vacío de una lista

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

  1. map: de un array de datos a un array de elementos

Array.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 React

Y 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/>[&lt;li&gt;, &lt;li&gt;, &lt;li&gt;]"]
    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.

  1. Devolver JSX desde map: el return que se olvida

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

  1. El refactor: ListaBicicletas con map

Este 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.
  • nombreEstacion ya no está escrito a mano. Antes decía «Plaza Mayor» para las dos primeras porque casualmente era cierto; ahora se resuelve a partir de bicicleta.estacionId, que es el dato real. Con el índice nombrePorEstacion, bici-003 muestra correctamente «Parque Norte» y bici-004, «Estación Central».
  • El estado vacío se comprueba con bicicletas.length === 0 mediante un retorno anticipado, como aprendiste en 03-02.
  • key={bicicleta.id}: cada tarjeta lleva la identidad estable que React necesita. Vamos a ello.

  1. Qué es key y por qué React la necesita

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

  1. 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 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}` Útil cuando no hay un id único propio
Un identificador generado al crear el dato (crypto.randomUUID()) 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.

  1. La lista nunca se reordena ni se filtra.
  2. Los elementos nunca se insertan ni se eliminan por el medio o el principio (solo se añaden al final, si acaso).
  3. 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.

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

  1. Marca como favorita solo bici-003 (Carga Max), la tercera de la lista. Su estrella se pone en ★.
  2. Pulsa «Eliminar» en bici-001 (la primera).
  3. 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.

  1. 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> Cada iteración produce varios hermanos
<div key={…}>…</div> 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.

  1. filter y sort sin mutar el array original

En 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 SITIO

Recuerda 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 toSorted o [...array].sort()
reverse toReversed o [...array].reverse()
splice toSpliced o filter
push / pop / shift 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:

  • visibles es 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.

  1. 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 map exterior usa cuerpo de bloque con return, porque necesita calcular enEstacion y libres antes de devolver el JSX. Es el caso 2 del apartado 3: si olvidas ese return, no se pinta ninguna estación.
  • Dos niveles de claves independientes: key={estacion.id} en las estaciones y key={bicicleta.id} en las bicicletas de cada una. Que bici-001 y est-01 no se parezcan es irrelevante; solo importa la unicidad entre hermanos.
  • enEstacion se calcula dentro del map, 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"]

  1. 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 return dentro de un map con llaves. La lista sale vacía y no hay ningún error. Recuerda: paréntesis devuelven, llaves ejecutan.
  • Usar forEach en lugar de map. forEach siempre devuelve undefined: 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 key en el elemento equivocado. Debe ir en el nodo más externo que devuelve el map, no en un hijo suyo.
  • Intentar leer props.key dentro 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 modelo como clave: bici-001 y bici-004 comparten modelo.
  • Usar <>…</> cuando hace falta una clave. La sintaxis corta no admite atributos; usa <Fragment key={…}>.
  • Ordenar con sort sobre las props o el estado. sort muta el array. Usa toSorted o 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 filter y toSorted justo antes del return. 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 TIPOS sin 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:

  1. Ejecútalo con key={indice}, marca como favorita bici-005 (la última) y súbela al principio. Describe qué ocurre con la estrella y explica por qué.
  2. 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

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