learning-front

Nivel 6 · React de cero a héroe (con TypeScript)

Listas y renderizado condicional

Pintar colecciones con .map() y su key, y mostrar una cosa u otra según los datos con && y el ternario. Aquí nace el grid de héroes del Team Builder a partir de un array.

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.

tsx
// 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:

tsx
// 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).

tsx
// 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.

tsx
// 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:

tsx
// 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 &&:

tsx
// 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:

tsx
// 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.

tsx
// 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
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.