En el capítulo anterior el Team Builder tenía tres héroes escritos a mano: <HeroCard hero={tracer} />, <HeroCard hero={mercy} />, <HeroCard hero={reinhardt} />. Con tres aguanta. Con treinta, imposible. Y si mañana el diseño de una tarjeta cambia, tienes que tocar los treinta. La solución es no escribir los elementos a mano: dejar que el array los genere.
Pintar una lista con .map()#
En JavaScript, .map() transforma un array en otro array. Hasta aquí nada nuevo. Lo que cambia en React es que el array de destino puede ser un array de elementos JSX, y React sabe pintarlos todos.
// Un array de strings.
const roles = ["Daño", "Apoyo", "Tanque"];
// .map() devuelve un array de elementos JSX: uno por cada rol.
const etiquetas = roles.map((rol) => <li>{rol}</li>);Para que React pinte ese array, lo metes dentro de {} en el JSX, igual que cualquier otra expresión:
// roles.map() está dentro de {}: React evalúa la expresión y pinta los elementos.
return (
<ul>
{roles.map((rol) => (
<li>{rol}</li>
))}
</ul>
);El .map() va directamente en el JSX, sin pasos intermedios. La arrow function dentro recibe cada elemento del array y devuelve el JSX correspondiente.
La prop key#
Si ejecutas el código anterior, React lo pinta pero avisa en la consola: Each child in a list should have a unique "key" prop. La razón es que React necesita saber, cuando el array cambia, qué elemento del render anterior corresponde a qué elemento del nuevo. Sin key, tiene que adivinar por posición, y cuando se insertan o eliminan elementos se equivoca.
La key es una prop especial que React usa para sí mismo: no llega al componente hijo ni aparece en el DOM. Solo necesita ser única dentro de su lista y estable (que no cambie entre renders).
// Cada <li> lleva key con el valor del rol: único en esta lista.
{roles.map((rol) => (
<li key={rol}>{rol}</li>
))}Para una lista de héroes, el nombre puede servir si sabes que no se repite. Pero lo más robusto es usar un id del dominio: un campo que existe en los datos precisamente para identificar el elemento de forma inequívoca.
// El id es el identificador del dominio: no cambia aunque cambie el nombre.
{equipo.map((hero) => (
<HeroCard key={hero.id} hero={hero} />
))}¿Por qué no el índice del array?#
La tentación es usar el índice: equipo.map((hero, i) => <HeroCard key={i} ... />). Para entender por qué es mala idea hay que saber qué hace React con la key, porque si no, parece que da igual.
Cuando la lista cambia, React empareja cada elemento del render anterior con uno del nuevo por su key, y con eso decide qué nodo del DOM reutilizar. Con una key estable (el id), React sabe que “este nodo sigue siendo la tarjeta del héroe 3, aunque ahora esté en otra posición”: lo conserva y lo mueve. Con la key igual al índice, React empareja por posición: el nodo que estaba en la posición 0 lo reutiliza para lo que ahora ocupe la posición 0, sea quien sea.
Y aquí viene el “¿y qué?”. Si tus tarjetas solo pintan datos de sus props —como las nuestras ahora—, no notarás nada raro: React reutiliza el nodo de la posición 0 y le sobrescribe el texto con los datos del héroe nuevo. El resultado en pantalla es correcto (solo que ha recreado y repintado más de lo necesario: un coste de rendimiento, no un bug visible). Un alumno crítico tendría razón: para esta lista exacta, el índice “funciona”.
El problema aparece en cuanto un elemento guarda estado que no está en tus datos: lo que el usuario escribió en un <input>, una casilla que marcó, qué elemento tiene el foco, una animación a medias. Ese estado vive en el nodo del DOM, no en tu array. Imagina que cada tarjeta tuviera una casilla para marcar al héroe:
// La casilla NO está en tus datos: lo que el usuario marque vive en el propio <input> del DOM.
<article className="tarjeta">
<h2>{hero.nombre}</h2>
<input type="checkbox" />
</article>Marcas la casilla de Tracer (posición 0) e insertas un héroe nuevo al principio del array. Ahora Tracer está en la posición 1 y el nuevo héroe en la 0. Con key = índice, React empareja por posición: reutiliza el nodo de la posición 0 —con su casilla ya marcada— y le sobrescribe el texto con los datos del héroe nuevo. Resultado: la marca aparece en el héroe equivocado. El estado se quedó pegado a la posición, no al héroe.
No crashea: hace algo peor, mentir en silencio. Y es difícil de detectar porque solo se manifiesta cuando la lista cambia y los elementos guardan estado; en una lista estática, o en un test que no reordena, todo parece correcto. Lo verás en carne propia en los próximos capítulos, cuando las tarjetas tengan inputs y estado propio.
La regla, entonces: usa un id estable de tus datos. El índice solo es seguro si la lista nunca se reordena, ni se filtra, ni se inserta en medio, y sus elementos no guardan nada por su cuenta: es decir, casi nunca.
Renderizado condicional con &&#
A veces quieres pintar algo solo cuando se cumple una condición, y no pintar nada si no se cumple. El patrón más común es el cortocircuito con &&:
// enForma es true o false según el winrate del héroe.
const enForma = winrate >= 60;
// Si enForma es true, React pinta el <span>; si es false, no pinta nada.
{enForma && <span className="en-forma">En forma</span>}Funciona porque en JavaScript false && algo evalúa a false, y React ignora false sin pintar ningún nodo. Solo cuando la condición es true se evalúa y pinta el elemento de la derecha.
Una precaución: si la condición es un número, && puede sorprenderte. {0 && <X />} pinta el texto "0", porque 0 no es false para React: es un valor falsy pero no es el booleano false. Conviértelo explícitamente: {conteo > 0 && <X />}.
Elegir entre dos opciones con el ternario#
Cuando tienes dos ramas (mostrar una cosa u otra), el ternario ? : es la herramienta:
// Si hay héroes, pinta el grid; si no, pinta el mensaje de estado vacío.
{equipo.length > 0 ? (
<div className="equipo">
{equipo.map((hero) => (
<HeroCard key={hero.nombre} hero={hero} />
))}
</div>
) : (
<p className="equipo-vacio">No hay héroes en el equipo.</p>
)}El ternario completo va dentro de {}, como cualquier expresión. Las ramas pueden ser elementos simples o fragmentos con varios elementos. Lo importante es que cada rama sea una expresión válida: un elemento JSX, un fragmento, o null si no quieres pintar nada en esa rama.
El estado vacío#
Un estado vacío bien hecho no es un lujo: es parte de la interfaz. Si un usuario llega a la pantalla del Team Builder con un equipo vacío y no ve nada, no sabe si hay un error, si está cargando, o si simplemente no tiene héroes.
// EmptyState: un componente con una sola responsabilidad.
function EmptyState() {
return <p className="equipo-vacio">No hay héroes en el equipo.</p>;
}
// App usa el ternario para elegir qué pintar según el estado del array.
export default function App() {
const hayHeroes = equipo.length > 0;
return (
<section>
<h1 className="titulo">Team Builder</h1>
{hayHeroes ? (
<div className="equipo">
{equipo.map((hero) => (
<HeroCard key={hero.id} hero={hero} />
))}
</div>
) : (
<EmptyState />
)}
</section>
);
}Extraer hayHeroes a una variable antes del return limpia el JSX: en vez de una expresión larga dentro de {}, lees un nombre que dice qué está pasando. El JSX queda como una plantilla que describe la interfaz, no como un bloque de lógica.
Pruébalo#
Primero, el grid básico: un array se convierte en tarjetas con .map() y key.
Ahora, los condicionales: un distintivo con && y el estado vacío con el ternario. Prueba a dejar el array equipo vacío ([]) para ver el estado vacío.
Comprueba lo que sabes#
Pregunta 1 de 4
¿Para qué sirve la prop key en una lista de React?
Tu turno#
Ejercicio · en esta página
El grid del Team Builder a partir de un array
Tienes el array equipo ya definido. Crea el componente HeroCard, pinta el grid completo con .map() y key, y gestiona el caso en que el array esté vacío.
Paso 1: La lista funciona
- HeroCard recibe { hero: Heroe } por props y pinta nombre, rol y winrate calculado
- App usa equipo.map() para devolver un HeroCard por héroe
- Cada HeroCard lleva la prop key con un valor único
- El grid de tarjetas aparece en pantalla
Paso 2: Condicional y distintivo
- Si equipo está vacío, aparece el mensaje 'No hay héroes en el equipo.'
- Si equipo tiene héroes, aparece el grid (no los dos a la vez)
- Los héroes con winrate >= 60 % muestran el distintivo 'En forma' con &&
- El ternario para el estado vacío está en el JSX, no con un if fuera del return
Paso 3: Key estable y árbol limpio
- El array usa un campo id numérico y key apunta a hero.id, no a hero.nombre
- El estado vacío vive en su propio componente EmptyState
- La condición hayHeroes está extraída a una variable antes del return
- El tier aguanta a 375px de ancho sin que las tarjetas se rompan
Ver soluciones
// La forma de un héroe.
interface Heroe {
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
// El equipo: un array de héroes con sus datos reales.
const equipo: Heroe[] = [
{ nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
];
// HeroCard recibe un héroe por props y pinta su tarjeta.
function HeroCard({ hero }: { hero: Heroe }) {
// El winrate es un dato derivado: nunca se almacena, se calcula.
const winrate = (hero.victorias / hero.partidas) * 100;
return (
<article className="tarjeta">
<h2 className="tarjeta__nombre">{hero.nombre}</h2>
<p className="tarjeta__rol">{hero.rol}</p>
<p className="tarjeta__winrate">{winrate.toFixed(0)}% de victorias</p>
</article>
);
}
// App pinta el array completo con .map(); key identifica cada elemento.
export default function App() {
return (
<section>
<h1 className="titulo">Team Builder</h1>
<div className="equipo">
{equipo.map((hero) => (
<HeroCard key={hero.nombre} hero={hero} />
))}
</div>
</section>
);
} Por qué este nivel
- El salto del capítulo: en vez de escribir <HeroCard /> una vez por héroe a mano, el array manda. Tres héroes, treinta héroes, cien héroes: el código no cambia. Solo cambia el array.
- key con hero.nombre es suficiente para una lista estática donde los nombres no se repiten. Más adelante verás por qué un id numérico del dominio es más robusto.
// La forma de un héroe.
interface Heroe {
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
// El equipo: un array de héroes con sus datos reales.
const equipo: Heroe[] = [
{ nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
];
// El umbral a partir del cual un héroe se considera "en forma".
const UMBRAL_EN_FORMA = 60;
// HeroCard recibe un héroe y pinta su tarjeta, con un distintivo condicional.
function HeroCard({ hero }: { hero: Heroe }) {
// Dato derivado: el winrate no se guarda, se calcula en cada render.
const winrate = (hero.victorias / hero.partidas) * 100;
// Si el winrate supera el umbral, el héroe está "en forma".
const enForma = winrate >= UMBRAL_EN_FORMA;
return (
<article className="tarjeta">
<h2 className="tarjeta__nombre">{hero.nombre}</h2>
<p className="tarjeta__rol">{hero.rol}</p>
<p className="tarjeta__winrate">{winrate.toFixed(0)}% de victorias</p>
{/* && solo pinta el distintivo cuando enForma es true */}
{enForma && <span className="en-forma">En forma</span>}
</article>
);
}
// App muestra el equipo completo o un mensaje si está vacío.
export default function App() {
return (
<section>
<h1 className="titulo">Team Builder</h1>
{/* Ternario: si hay héroes, el grid; si no, el mensaje de estado vacío */}
{equipo.length > 0 ? (
<div className="equipo">
{equipo.map((hero) => (
<HeroCard key={hero.nombre} hero={hero} />
))}
</div>
) : (
<p className="equipo-vacio">No hay héroes en el equipo.</p>
)}
</section>
);
} Por qué es mejor que el anterior
- El ternario en el JSX cubre los dos estados posibles: array con héroes → grid; array vacío → mensaje. Si el array cambiara en el futuro (cuando aprendas estado), la interfaz reaccionaría sola.
- && para el distintivo: más limpio que un ternario cuando la alternativa es 'no pintar nada'. Solo aparece cuando la condición es true; si es false, React descarta el elemento sin dejar rastro en el DOM.
// La forma de un héroe, con un id estable para usar como key.
interface Heroe {
id: number;
nombre: string;
rol: string;
partidas: number;
victorias: number;
}
// El equipo: un array de héroes. Cada uno tiene un id numérico único.
const equipo: Heroe[] = [
{ id: 1, nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ id: 2, nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 150 },
{ id: 3, nombre: "Reinhardt", rol: "Tanque", partidas: 95, victorias: 52 },
{ id: 4, nombre: "Ana", rol: "Apoyo", partidas: 88, victorias: 61 },
];
// A partir de este winrate el héroe se considera "en forma".
const UMBRAL_EN_FORMA = 60;
// HeroCard: solo sabe de UN héroe. Toda la lógica derivada va aquí.
function HeroCard({ hero }: { hero: Heroe }) {
// Dato derivado: se calcula, no se almacena.
const winrate = (hero.victorias / hero.partidas) * 100;
// El estado "en forma" es también un dato derivado del winrate.
const enForma = winrate >= UMBRAL_EN_FORMA;
return (
<article className="tarjeta">
<h2 className="tarjeta__nombre">{hero.nombre}</h2>
<p className="tarjeta__rol">{hero.rol}</p>
<p className="tarjeta__winrate">{winrate.toFixed(0)}% de victorias</p>
{/* && con la condición a la izquierda: si es false, React no pinta nada */}
{enForma && <span className="en-forma">En forma</span>}
</article>
);
}
// EmptyState: componente pequeño con una sola responsabilidad: el estado vacío.
function EmptyState() {
return <p className="equipo-vacio">No hay héroes en el equipo.</p>;
}
// App: dueña de los datos y punto de entrada del árbol de componentes.
export default function App() {
// Variable derivada: ¿hay héroes o no? Se pregunta a los datos, no al DOM.
const hayHeroes = equipo.length > 0;
return (
<section>
<h1 className="titulo">Team Builder</h1>
{/* Ternario: una de dos ramas, según el estado del array */}
{hayHeroes ? (
<div className="equipo">
{/* key con el id del dominio: estable aunque el nombre cambie */}
{equipo.map((hero) => (
<HeroCard key={hero.id} hero={hero} />
))}
</div>
) : (
<EmptyState />
)}
</section>
);
} Por qué es mejor que el anterior
- key con hero.id en vez de hero.nombre: el id es un dato del dominio que no cambia aunque el héroe se renombre. El nombre puede repetirse o cambiar; el id no. Así React siempre empareja bien los nodos.
- EmptyState como componente separado: una sola responsabilidad por componente. Si el diseño del estado vacío cambia, solo tocas EmptyState. App no mezcla lógica de presentación del vacío con lógica del grid.
- hayHeroes extraída antes del return: el JSX queda como una plantilla que lee variables, sin lógica incrustada. Más fácil de leer de un vistazo.