Módulo 5: Derived State And Lists

La `key` cuando la lista cambia

Descripción

En el módulo 2 conociste la key y viste, ejecutado, qué se rompe cuando usas el índice como key al insertar un elemento al frente de una lista con estado local: el estado (un checkbox marcado) saltaba al elemento equivocado. Aquella lección terminó con una promesa: el problema se vuelve cotidiano cuando la lista se filtra en vivo, "módulos 5 en adelante". Ya llegamos. Esta lección salda esa deuda con la lista derivada de este módulo.

Porque ahora la conexión es directa. En el módulo 2 tenías que imaginar una lista que cambiaba. Aquí no hay que imaginar nada: acabas de construir, en la lección 5, un ProductList que cambia de orden y de miembros en cada render —filtras por búsqueda y desaparecen productos; cambias el orden y se reordenan todos—. Esa lista derivada es el escenario perfecto para el bug de la key, porque el filtrado y el reordenamiento son precisamente las operaciones que rompen el índice como key. Si el estado local de un elemento (un checkbox de "comparar", un input a medio escribir, el foco) se pega a la posición en vez de al producto, filtrar o reordenar lo manda al elemento equivocado —o lo hace desaparecer—. Y lo vas a ver ejecutado, en los dos escenarios: reordenar y filtrar.

Conexión con el módulo. Esta lección conecta el estado derivado (el tema del módulo) con la key (la deuda del módulo 2). La razón por la que la key importa tanto aquí es que derivar produce listas que cambian todo el tiempo: cada tecla en la búsqueda, cada clic en el orden, reconstruye la lista visible con otros miembros y otro orden. Con listas estáticas, la key mala pasa inadvertida; con listas derivadas, es un bug garantizado. Usamos el mismo modelo de reconciliación que en el módulo 2, ahora manejado por las operaciones de este módulo.

Una analogía: los casilleros del gimnasio

Retoma la imagen del guardarropa del módulo 2, pero llévala a un gimnasio con casilleros. Hay dos formas de asignarte un casillero. En la buena, te dan un candado con tu nombre grabado: tu candado es tuyo, y lo pones en el casillero que sea. Si mañana reorganizan los casilleros, o quitan unos y agregan otros, no importa: tu candado —con tu nombre— sigue tus cosas. Buscas tu nombre y ahí están tus pertenencias.

En la mala, el gimnasio no graba tu nombre en el candado: te asigna "el casillero número 3" y anota tus cosas como "las del 3". Mientras nadie mueva nada, funciona. Pero el gimnasio reordena los casilleros cada día según cuáles se usan más (imagina eso: los populares al frente). Hoy tus cosas están en el "3". Mañana, tras el reordenamiento, el casillero que quedó en la posición 3 es otro, con las cosas de otra persona —pero el sistema sigue diciendo "las del 3 son tuyas"—. Abres el 3 y encuentras las pertenencias de un desconocido. El sistema no falló: hizo lo que le pediste, seguir la posición, no la persona.

La key es el candado. Grábale el nombre del dueño —el id del producto— y sigue al producto por más que la lista se reordene o se filtre: el estado local (el checkbox, el input) va con el producto. Grábale el número de casillero —el índice, la posición— y en cuanto la lista cambia de orden, el estado se queda pegado a la posición y termina en el producto equivocado. En un ProductList que se reordena y se filtra con cada interacción, "el número de casillero" cambia constantemente. Por eso la key estable no es opcional aquí: es la única que sobrevive al cambio.

Qué hace React con la key (repaso del módulo 2)

Un recordatorio breve del mecanismo, porque es la base de todo. React no borra y redibuja la lista entera en cada render —perdería el estado local, el foco, el texto a medio escribir—. En vez de eso, reconcilia: compara la lista de elementos de antes con la de ahora y decide qué reutilizar. Para emparejar "el de antes" con "el de ahora" usa la key. Un elemento cuya key reaparece se considera el mismo: React reutiliza su instancia y conserva su estado local (el checked de un checkbox, el valor de un input no controlado, el foco). Ese estado no vive en tus datos; vive en el elemento, y React lo sigue por la key.

De ahí la regla: si la key es el id del dato (estable), el estado sigue al dato aunque cambie de posición. Si la key es el índice (la posición), el estado se pega a la posición y salta al elemento que caiga ahí. En el módulo 2 lo viste al insertar; aquí lo verás al reordenar y al filtrar —las dos operaciones de una lista derivada—.

Ejemplo trabajado: el estado local frente al filtrar y al reordenar

Usamos el mismo modelo de reconciliación del módulo 2 (reconcileByKey): empareja las filas viejas con los productos nuevos por key, conservando el checked de la fila cuya key reaparece. Corremos dos escenarios —reordenar y filtrar— cada uno con las dos estrategias (key = id y key = índice), sobre el ProductList de Mercado.

'use strict';
// La 'key' cuando la lista DERIVADA cambia (se reordena o se filtra).
// Modelo mínimo de la reconciliación de React POR KEY (igual que en M2):
// React conserva un ESTADO LOCAL por fila (aquí: un checkbox "compare").
// Empareja por 'key' entre un render y el siguiente. Si la key es el id,
// el estado sigue al producto; si es el índice, se pega a la posición.

function reconcileByKey(oldRows, newProducts, keyOf) {
  const oldByKey = new Map(oldRows.map((r) => [r.key, r]));
  return newProducts.map((product, index) => {
    const key = keyOf(product, index);
    const prev = oldByKey.get(key);
    return { key, product, checked: prev ? prev.checked : false };
  });
}
function show(rows) {
  return rows
    .map((r) => `  [${r.checked ? 'x' : ' '}] ${r.product.name.padEnd(20)} (key=${r.key})`)
    .join('\n');
}

const products = [
  { id: 'p1', name: 'Wireless Mouse',      priceCents: 2599 },
  { id: 'p2', name: 'Mechanical Keyboard', priceCents: 8900 },
  { id: 'p3', name: 'USB-C Hub',           priceCents: 3499 },
  { id: 'p4', name: 'Laptop Stand',        priceCents: 4500 },
  { id: 'p5', name: 'Desk Lamp',           priceCents: 1999 },
];
const asc  = (a, b) => a.priceCents - b.priceCents;
const desc = (a, b) => b.priceCents - a.priceCents;

// ─────────────────────────────────────────────────────────────────────────
// ESCENARIO A: REORDENAR. La lista visible está ordenada asc; el usuario marca
// "Wireless Mouse"; luego cambia el orden a desc y la lista derivada se reordena.
// ─────────────────────────────────────────────────────────────────────────
const ascList  = products.slice().sort(asc);
const descList = products.slice().sort(desc);

console.log('=== ESCENARIO A: el usuario reordena (asc -> desc) ===\n');
console.log('Antes (orden asc, el usuario marcó "Wireless Mouse"):');
let rowsById = ascList.map((p) => ({
  key: p.id, product: p, checked: p.name === 'Wireless Mouse',
}));
let rowsByIx = ascList.map((p, i) => ({
  key: i, product: p, checked: p.name === 'Wireless Mouse',
}));
console.log(show(rowsById));

console.log('\nDespués (orden desc)  con key = product.id  (ESTABLE):');
console.log(show(reconcileByKey(rowsById, descList, (p) => p.id)));
console.log('\nDespués (orden desc)  con key = índice  (SE ROMPE):');
console.log(show(reconcileByKey(rowsByIx, descList, (p, i) => i)));

// ─────────────────────────────────────────────────────────────────────────
// ESCENARIO B: FILTRAR. Sin query el usuario marca "Mechanical Keyboard";
// luego teclea 'e' y la lista derivada se ACORTA (se caen Hub y Stand).
// ─────────────────────────────────────────────────────────────────────────
const full = products.slice().sort(asc);
const filtered = full.filter((p) => p.name.toLowerCase().includes('e'));

console.log('\n=== ESCENARIO B: el usuario filtra (teclea "e") ===\n');
console.log('Antes (sin query, marcó "Mechanical Keyboard"):');
let fRowsById = full.map((p) => ({
  key: p.id, product: p, checked: p.name === 'Mechanical Keyboard',
}));
let fRowsByIx = full.map((p, i) => ({
  key: i, product: p, checked: p.name === 'Mechanical Keyboard',
}));
console.log(show(fRowsById));

console.log('\nDespués (query="e")  con key = product.id  (ESTABLE):');
console.log(show(reconcileByKey(fRowsById, filtered, (p) => p.id)));
console.log('\nDespués (query="e")  con key = índice  (SE ROMPE):');
console.log(show(reconcileByKey(fRowsByIx, filtered, (p, i) => i)));

En React de verdad, el ProductList con la key correcta se ve así —cada ProductCard con un checkbox de estado local, y key={p.id}—:

function ProductList({ visible }) {
  return (
    <ul className="product-list">
      {visible.map((p) => (
        <ProductCard key={p.id} product={p} /> // key = id: el estado local sigue al producto
      ))}
    </ul>
  );
}

Qué esperar. Al correr el archivo con Node, la salida es exactamente esta:

=== ESCENARIO A: el usuario reordena (asc -> desc) ===

Antes (orden asc, el usuario marcó "Wireless Mouse"):
  [ ] Desk Lamp            (key=p5)
  [x] Wireless Mouse       (key=p1)
  [ ] USB-C Hub            (key=p3)
  [ ] Laptop Stand         (key=p4)
  [ ] Mechanical Keyboard  (key=p2)

Después (orden desc)  con key = product.id  (ESTABLE):
  [ ] Mechanical Keyboard  (key=p2)
  [ ] Laptop Stand         (key=p4)
  [ ] USB-C Hub            (key=p3)
  [x] Wireless Mouse       (key=p1)
  [ ] Desk Lamp            (key=p5)

Después (orden desc)  con key = índice  (SE ROMPE):
  [ ] Mechanical Keyboard  (key=0)
  [x] Laptop Stand         (key=1)
  [ ] USB-C Hub            (key=2)
  [ ] Wireless Mouse       (key=3)
  [ ] Desk Lamp            (key=4)

=== ESCENARIO B: el usuario filtra (teclea "e") ===

Antes (sin query, marcó "Mechanical Keyboard"):
  [ ] Desk Lamp            (key=p5)
  [ ] Wireless Mouse       (key=p1)
  [ ] USB-C Hub            (key=p3)
  [ ] Laptop Stand         (key=p4)
  [x] Mechanical Keyboard  (key=p2)

Después (query="e")  con key = product.id  (ESTABLE):
  [ ] Desk Lamp            (key=p5)
  [ ] Wireless Mouse       (key=p1)
  [x] Mechanical Keyboard  (key=p2)

Después (query="e")  con key = índice  (SE ROMPE):
  [ ] Desk Lamp            (key=0)
  [ ] Wireless Mouse       (key=1)
  [ ] Mechanical Keyboard  (key=2)

Dos escenarios, cada uno con su moraleja.

Escenario A — reordenar. Antes, el usuario marcó "Wireless Mouse" (posición 1 en orden ascendente). Luego cambió el orden a descendente, y la lista derivada se reordenó por completo. Mira dónde queda la marca:

  • Con key = product.id, la marca sigue a "Wireless Mouse" hasta su nueva posición (la cuarta fila en orden descendente). Correcto: el usuario marcó ese producto, y ahí está la marca. El candado tenía el nombre grabado, y siguió al dueño.
  • Con key = índice, la marca se quedó en la posición 1 —que ahora ocupa "Laptop Stand"—. El usuario marcó "Wireless Mouse", pero la interfaz muestra "Laptop Stand" marcado, un producto que nunca tocó. Y "Wireless Mouse", ahora en la posición 3, aparece sin marca. El estado saltó al producto equivocado, porque se pegó a la posición.

Escenario B — filtrar. Antes, sin búsqueda, el usuario marcó "Mechanical Keyboard" (última posición, índice 4). Luego tecleó "e", y la lista se acortó a tres productos (Desk Lamp, Wireless Mouse, Mechanical Keyboard); USB-C Hub y Laptop Stand se cayeron por no tener "e". Mira la marca:

  • Con key = product.id, la marca sigue a "Mechanical Keyboard" hasta su nueva posición (tercera fila de la lista filtrada). Correcto: el producto sobrevivió al filtro, y su marca con él.
  • Con key = índice, la marca desapareció. "Mechanical Keyboard" estaba en el índice 4, pero la lista filtrada solo tiene índices 0, 1 y 2 —el elemento del índice 4 se desmontó y su estado se perdió—. Peor: el producto que ahora está en el índice 2 (Mechanical Keyboard) heredó el estado del viejo índice 2 (USB-C Hub), que estaba sin marcar. Resultado: el producto que el usuario había marcado aparece sin marca, y la marca simplemente se esfumó. El estado no saltó a otro producto: se perdió del todo.

Junta las dos moralejas. Con key = id, el estado local siempre hizo lo correcto: siguió al producto al reordenar, sobrevivió al filtro. Con key = índice, el estado hizo dos cosas malas distintas —saltar al producto equivocado (reordenar) y perderse (filtrar)—, ambas silenciosas, ambas provocadas por operaciones que en una lista derivada pasan con cada interacción. Esa es la deuda del módulo 2, cobrada: en un ProductList que se filtra y se reordena, el índice como key es un bug garantizado.

Profundización: el perfil de una key correcta (y por qué falla el índice aquí)

De todo lo anterior sale, otra vez, el perfil de una buena key —único y estable, del id del dato—, pero ahora con la razón concreta de este módulo:

  • Única entre hermanos. Dos filas de la misma lista no pueden compartir key. El id del producto lo garantiza.
  • Estable en el tiempo. El mismo producto debe tener siempre la misma key, render tras render. Aquí está el punto: el índice no es estable cuando la lista se filtra o se reordena. "Wireless Mouse" tenía índice 1 en orden ascendente y pasó a índice 3 en descendente; "Mechanical Keyboard" tenía índice 4 sin filtro y pasó a índice 2 con filtro. Su índice cambió aunque el producto es el mismo, y por eso React perdió la pista de su estado. El id (p1, p2) no cambió, y por eso lo mantuvo.
  • Derivada de la identidad del dato. El id que ya traen tus productos (product.id). No lo inventes en el render.

Y el recordatorio de las malas key, con su síntoma en una lista derivada:

Key                     Problema                          En una lista derivada
──────────────────────  ────────────────────────────────  ───────────────────────────
index                   no es estable: cambia al           al filtrar/reordenar (o sea,
                        filtrar/reordenar/insertar         en CADA interacción) el estado
                                                          salta o se pierde
Math.random()           cambia en CADA render              React recrea toda la lista cada
                                                          tecla: pierde foco y estado
un campo no único       dos productos comparten key        cuando dos coinciden en ese campo
(p. ej. category)                                         (aquí, misma categoría)
product.id (bueno)      único y estable                    sobrevive a filtrar y reordenar

Fíjate en que category sería una key pésima aquí: tres productos comparten peripherals y dos comparten furniture, así que como key colisionaría. La key debe ser única entre los hermanos de la lista, y solo el id lo garantiza en un catálogo real.

Errores comunes

Usar el índice como key en la lista filtrada. Qué pasa: visible.map((p, i) => <ProductCard key={i} ... />). Por qué pasa: el índice está a la mano en el .map(), y con datos estáticos el bug no se ve. Cómo detectarlo: al filtrar o reordenar, un checkbox aparece marcado en el producto equivocado, un input muestra el texto de otra fila, el foco brinca, o el estado de un elemento desaparece —justo lo que viste ejecutado—. Cómo corregirlo: key={p.id}. En una lista que se deriva (filtra/ordena) en cada interacción, el índice es un bug garantizado, no un riesgo teórico.

Creer que "como igual re-renderiza, la key da lo mismo". Qué pasa: se piensa que si la lista se recalcula entera, React va a "rehacer todo" y la key no importa. Por qué pasa: se confunde "recalcular la lista de datos" (que sí pasa) con "recrear los elementos del DOM y su estado" (que React evita reconciliando por key). Cómo detectarlo: el estado local sobrevive entre renders cuando no debería, o salta, sin que entiendas por qué. Cómo corregirlo: recuerda que React conserva el estado local de los elementos cuya key reaparece —justamente para no perder el foco ni el texto—; por eso la key decide a qué elemento se le conserva el estado. Recalcular los datos no implica recrear los elementos: la key gobierna eso.

Math.random() como key "para asegurar unicidad". Qué pasa: key={Math.random()}. Por qué pasa: se confunde "única" con "aleatoria". Cómo detectarlo: en una lista que se filtra en vivo, cada tecla genera key nuevas para todos, así que React destruye y recrea la lista entera en cada render —el input de búsqueda pierde el foco a cada letra, las animaciones se reinician, todo parpadea—. Cómo corregirlo: la key debe ser única y estable: la misma para el mismo producto entre renders. Usa product.id, jamás un valor aleatorio.

Ejercicios

Ejercicio 1 — Predice el reordenamiento. Con los datos del ejemplo trabajado, en orden ascendente el usuario marca "USB-C Hub" (posición 2, $34.99). Luego cambia a orden descendente. Con key = índice, ¿en qué producto aparece la marca después? ¿Y con key = id? (Orden asc: Desk Lamp, Wireless Mouse, USB-C Hub, Laptop Stand, Mechanical Keyboard. Desc: Mechanical Keyboard, Laptop Stand, USB-C Hub, Wireless Mouse, Desk Lamp.)

Ver solución

"USB-C Hub" está en la posición 2 (índice 2) tanto en ascendente como en descendente —es el del medio de cinco, y al invertir cinco elementos el del medio se queda en el medio—.

Con key = índice: la marca estaba en el índice 2. Tras reordenar, el índice 2 sigue siendo "USB-C Hub". Así que, por casualidad, la marca queda correcta. Este es un detalle instructivo: el índice como key no rompe siempre, solo cuando el elemento marcado cambia de posición. El del medio de una lista impar invertida no cambia, así que no se nota el bug aquí —lo cual lo hace más traicionero, porque a veces "funciona"—.

Con key = id: la marca sigue a "USB-C Hub" (key=p3) esté donde esté; como no se movió, también queda correcta. La diferencia: con key = id es correcta por construcción; con key = índice fue correcta por suerte (el elemento no se movió). Marca un elemento que sí cambie de posición y el índice fallará.

Ejercicio 2 — Diagnostica la desaparición. En el Escenario B (filtrar con key = índice), el checkbox de "Mechanical Keyboard" no salta a otro producto: desaparece. Explica el mecanismo: por qué el estado se pierde en vez de saltar, usando los índices.

Ver solución

Antes del filtro, "Mechanical Keyboard" estaba en el índice 4 (último de cinco), con checked = true. Los índices 0..4 tenían el estado: 0 Desk Lamp (F), 1 Wireless Mouse (F), 2 USB-C Hub (F), 3 Laptop Stand (F), 4 Mechanical Keyboard (T).

Tras teclear "e", la lista filtrada tiene solo tres elementos (índices 0, 1, 2): Desk Lamp, Wireless Mouse, Mechanical Keyboard. React empareja por key = índice:

  • Nuevo índice 0 (Desk Lamp) → busca el viejo key=0 (Desk Lamp, F) → F.
  • Nuevo índice 1 (Wireless Mouse) → viejo key=1 (Wireless Mouse, F) → F.
  • Nuevo índice 2 (Mechanical Keyboard) → viejo key=2 (USB-C Hub, F) → F. Mechanical Keyboard hereda el estado del viejo índice 2, que estaba sin marcar.

El viejo key=4 (donde vivía la marca) ya no existe en la lista nueva —solo llega hasta el índice 2—, así que ese elemento se desmonta y su estado (checked=true) se destruye. La marca no saltó a otro producto: desapareció con el elemento del índice 4. Y "Mechanical Keyboard", en su nuevo índice 2, tomó el estado sin-marca del viejo índice 2. Por eso el producto que el usuario había marcado aparece desmarcado. Con key = id, "Mechanical Keyboard" (p2) conserva su key sin importar su índice, así que su checked=true lo sigue: la marca sobrevive al filtro.

Ejercicio 3 — Elige la key. Para el ProductList de Mercado, evalúa cada candidata a key en el contexto de una lista que se filtra y se ordena en vivo, y di si es buena o mala y por qué: (a) key={p.id}; (b) key={index}; (c) key={p.category}; (d) key={p.name}; (e) key={p.priceCents}.

Ver solución
  • (a) p.idBuena. Única y estable; sobrevive a filtrar y reordenar. La correcta.
  • (b) indexMala. No es estable: cambia con cada filtro y cada reordenamiento, que es cada interacción con esta lista. El estado local salta o se pierde, como viste.
  • (c) p.categoryMala (colisiona). Varios productos comparten categoría (tres peripherals, dos furniture), así que como key no es única entre hermanos. React no podría distinguirlos.
  • (d) p.nameAceptable con reservas. Es única y estable si garantizas que los nombres nunca se repiten ni se editan. Pero los nombres pueden repetirse (dos "Cable") o cambiar; el id es más seguro. Prefiere id.
  • (e) p.priceCentsMala. No es única (dos productos pueden costar lo mismo) ni estable (un precio puede cambiar con una rebaja —justo lo que pasó en la lección 4—). Doble falla.

Solo p.id cumple los dos requisitos (única y estable) sin condiciones. En una lista derivada que cambia con cada interacción, cualquier otra opción es un bug esperando su momento.

Resumen y siguiente paso

En esta lección saldaste la deuda de la key del módulo 2, ahora con la lista derivada de este módulo. Como el ProductList cambia de orden y de miembros en cada render —al filtrar, al reordenar—, la key estable ligada al id es lo único que mantiene el estado local pegado al producto correcto. Lo viste ejecutado en dos escenarios: al reordenar (asc → desc), con key = id la marca siguió a "Wireless Mouse" y con key = índice saltó a "Laptop Stand"; al filtrar (teclear "e"), con key = id la marca sobrevivió en "Mechanical Keyboard" y con key = índice desapareció. El índice falla porque no es estable cuando la lista se filtra o se ordena —es decir, en cada interacción—; el id no cambia, y por eso funciona.

Antes de avanzar deberías poder: explicar por qué una lista derivada hace crítica la key estable; describir los dos síntomas del índice (el estado salta al reordenar, se pierde al filtrar); y elegir key={p.id} justificando por qué index, category y priceCents fallan.

La lección 7 cierra el módulo con la síntesis práctica: el procedimiento para decidir estado vs derivado aplicado a todo el storefront, más los dos anti-patrones que hay que evitar. Verás, ejecutado, por qué sincronizar un estado con un effect llega tarde —el render queda stale y sobra uno— frente a derivar, que es correcto en el mismo render (un adelanto de "You Might Not Need an Effect", el módulo 6). Y medirás el costo de derivar para entender por qué useMemo casi nunca hace falta con listas pequeñas —y dónde queda la frontera con la guía de performance—.

Recursos