Listas y claves
En resumen: Una lista en React se hace con map a partir de un array de componentes, y key le dice a React qué fila vieja es cada fila nueva; un número aleatorio es una mala clave, y el índice también lo es en cuanto la lista cambia.
Problema
Quité el pan y la leche pasó a ser 3
El comprador tiene pan, leche y queso en el carrito. Escribe 3 para el pan y 2 para la leche, y después quita el pan. Pruébalo: cambia los dos números y pulsa «Quitar» junto al pan.
Consola
El pan ya no está, pero la leche ahora es 3 y el queso es 2. En la consola no hay ningún error. Los datos son correctos: se quitó justo el pan. Solo los campos están mezclados. La causa es un solo atributo en la línea 20 de App.jsx: lo que el código le dice a la biblioteca sobre cada fila de la lista.
La idea
Una lista en un componente
En el módulo «JavaScript en la página» una lista se hacía a partir de un array con map y createElement. En un componente la idea es la misma, pero cada elemento del array pasa a ser parte del marcado:
<ul> {items.map((p) => ( <li key={p.id}> <ProductCard product={p} /> </li> ))}</ul>map devuelve un array de elementos JSX, y React los dibuja uno tras otro. Cada elemento del array tiene un atributo key: la clave con la que React lo reconoce.
Cuando los datos cambian, el componente devuelve una lista nueva. La biblioteca no borra la vieja para dibujarla otra vez. Compara la vieja con la nueva y cambia en la página solo la diferencia. Por eso el foco y lo escrito en los campos se conservan, en lugar de perderse como con render() en el módulo «JavaScript en la página».
Para comparar tiene que saber qué fila vieja corresponde a qué fila nueva. Eso es lo que dice la clave.
La idea
Emparejar por clave
La biblioteca empareja las filas viejas y nuevas por la clave:
- La misma clave es la misma fila. El elemento de la página se queda, y la biblioteca cambia dentro solo lo que difiere en los datos: el nombre, el precio.
- Una clave que no está en la lista nueva es una fila quitada. Su elemento se quita de la página.
- Una clave nueva es una fila nueva. Se crea un elemento nuevo.
El elemento que se queda conserva todo lo suyo: lo escrito en un <input>, el foco, un menú desplegable abierto. Eso no está en los datos sino en el propio elemento. Por eso importa que se quede con su producto.
En el ejemplo la clave es el índice: el lugar en el array. Tras «Quitar», la leche está en el lugar 0. La fila vieja con la clave 0 era la del pan. La biblioteca la conserva, cambia su nombre a «Leche fresca» y deja el campo con el 3.
Míralo
Ver el emparejamiento
A la izquierda está la página antes de «Quitar», a la derecha la lista nueva del componente. Fíjate en qué fila desaparece.
// key = el lugar en la lista{items.map((p, index) => ( <li key={index}> <ProductCard product={p} /> </li>))}
// key = el id del producto{items.map((p) => ( <li key={p.id}> <ProductCard product={p} /> </li>))}Paso a paso, en palabras
- Tres filas en el carrito, y la clave es el lugar: 0, 1, 2. El comprador ha escrito 3, 2 y 1 en los campos. Lo escrito vive en el elemento <input>, no en los datos.
- «Quitar Pan blanco». Los datos ya tienen dos productos, y el componente devuelve una lista nueva: la clave 0 es la leche, la clave 1 es el queso.
- La biblioteca empareja la lista vieja y la nueva por la clave: 0 con 0, 1 con 1. La clave 2 no está en la nueva, así que desaparece la tercera fila: la del queso.
- Las filas que se quedan reciben los datos nuevos, y sus encabezados ahora dicen leche y queso. Los campos son los viejos, con 3 y 2. La cantidad del pan está ahora en la leche.
- El mismo carrito con key = id. Las claves son 1, 2, 3 y son del producto, no del lugar.
- Otra vez «Quitar Pan blanco». La lista nueva tiene las claves 2 y 3.
- La clave 2 se empareja con la 2, y la 3 con la 3, aunque hayan subido. Falta la clave 1, así que desaparece la fila del pan, junto con su campo.
- Cada campo se ha quedado con su producto: leche 2, queso 1. La clave le dice a la biblioteca quién es quién, no dónde está.
Con el índice desaparece la última fila, y las dos primeras cambian de nombre. Con el id desaparece la fila del pan, y las otras dos solo suben con sus campos. En ambos casos los datos son los mismos. La diferencia es lo que dice la clave: el lugar o el producto.
Pruébalo
Arregla la clave
Arregla el carrito para que, tras «Quitar», cada número se quede con su producto. La comprobación escribe 3 para el pan y 2 para la leche, quita el pan y mira los campos de la leche y del queso.
Consola
Solución
App.jsximport { useState } from 'react';
import ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 2, name: 'Leche fresca', price: 1.6 },
{ id: 3, name: 'Queso blanco', price: 7.9 },
];
export default function App() {
// El estado (módulo siguiente): la lista que cambian los botones.
const [items, setItems] = useState(PRODUCTS);
const remove = (id) => setItems(items.filter((p) => p.id !== id));
return (
<main>
<h1>El carrito</h1>
<ul>
{items.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
<button type="button" aria-label={`Quitar ${p.name}`} onClick={() => remove(p.id)}>Quitar</button>
</li>
))}
</ul>
</main>
);
}
ProductCard.jsxconst money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Cantidad <input type="number" min="1" defaultValue="1" aria-label={`Cantidad: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
La clave pasa a ser el id del producto. Es la misma en cada dibujado y distinta para cada producto. Tras «Quitar» falta la clave 1, así que desaparece la fila del pan, y las filas de la leche y del queso se quedan con sus campos. Sin clave la biblioteca vuelve a comparar por lugar y el error sigue.
Pruébalo
Completa el hueco
¿Cuál es el atributo con el que la biblioteca reconoce una fila?
{items.map((p) => (
<li ={p.id}>
<ProductCard product={p} />
</li>
))}key: React lo lee para emparejar las filas y no se lo pasa al componente.
La idea
Una buena clave
Una buena clave es estable y única: la misma para el mismo producto en cada dibujado, y distinta para productos distintos. Lo mejor es un id de los datos: viene de la base de datos y no cambia.
- El índice es una mala clave si la lista cambia: al quitar, al añadir al principio, al ordenar, al filtrar. Sirve si la lista es solo de lectura y nunca cambia de orden.
Math.random()es siempre una mala clave. En cada dibujado la clave es nueva, así que ninguna fila vieja tiene pareja. La biblioteca quita todas las filas y hace otras nuevas. Los campos pierden lo escrito, y el foco desaparece.- Dos claves iguales confunden el emparejamiento. Aquí la clave es el nombre, y «Yogur» hay en dos tamaños. Pulsa «Ordenar por precio» y cuenta las filas.
Consola
Son cuatro productos, y las filas pasan a ser cinco: un yogur está dos veces.
React lo dice ya en el primer dibujado, con un error en la consola: «Encountered two children with the same key,Yogur». Si te olvidas de la clave, el error es «Each child in a list should have a unique "key" prop.» Los dos mensajes los da React en modo de desarrollo. En el sitio real la consola calla, y el error en la página sigue.Pruébalo
Encuentra el error
En cada dibujado todos los campos «Cantidad» vuelven a 1, y el cursor sale del campo.
Elige la línea con el error.
Línea 3: key={Math.random()} es una clave nueva en cada dibujado. Ninguna fila vieja tiene pareja, y React hace todas de nuevo. La clave tiene que ser p.id.
La idea
Filtrar y ordenar
El catálogo muestra solo los productos disponibles, ordenados por precio. Es una lista derivada: no se guarda aparte, sino que se hace a partir de los datos en cada dibujado.
const visible = PRODUCTS .filter((p) => p.stock > 0) .sort((a, b) => a.price - b.price);Esto está en el cuerpo del componente: cada dibujado vuelve a hacer visible a partir de PRODUCTS.
filterdevuelve un array nuevo solo con los productos disponibles. El original queda entero.sortordena el array en el sitio. Aquí es seguro, porque va después defilter, que ya dio una copia. Sin filtro, antes haces una copia:[...PRODUCTS].sort(…).- La clave sigue siendo
p.id. Al ordenar, las filas cambian de lugar, y el índice cambiaría la clave de cada una.
Pruébalo
Solo lo que está disponible
Muestra solo los productos disponibles (stock > 0), ordenados por precio, del más barato al más caro. Encima de la lista escribe «Disponibles: 3 de 5».
Consola
Solución
App.jsximport ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 3, name: 'Queso blanco', price: 7.9, stock: 4 },
{ id: 4, name: 'Banitsa', price: 2.5, stock: 0 },
{ id: 1, name: 'Pan blanco', price: 1.2, stock: 10 },
{ id: 5, name: 'Pepinos', price: 2.2, stock: 0 },
{ id: 2, name: 'Leche fresca', price: 1.6, stock: 6 },
];
export default function App() {
const visible = PRODUCTS.filter((p) => p.stock > 0).sort((a, b) => a.price - b.price);
return (
<main>
<h1>El catálogo</h1>
<p>Disponibles: {visible.length} de {PRODUCTS.length}</p>
<ul>
{visible.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
</li>
))}
</ul>
</main>
);
}
ProductCard.jsxconst money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Cantidad <input type="number" min="1" defaultValue="1" aria-label={`Cantidad: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
PRODUCTS.filter((p) => p.stock > 0) quita la banitsa y los pepinos, y .sort((a, b) => a.price - b.price) ordena el pan, la leche y el queso. Los números salen de los dos arrays: visible.length y PRODUCTS.length. La comprobación mira el orden de los encabezados. No ve la clave: en esta lista nada cambia después de dibujarla. La clave importa en cuanto algo cambia, como en el ejercicio.
Más precisamente, para curiosos
key no es una prop. La biblioteca se queda con la clave y no se la pasa al componente. props.key en ProductCard es undefined, y React escribe el error «key is not a prop». Si el componente necesita el id, pásalo aparte: product ya lo contiene.
Una clave para reiniciar. Una clave nueva es un elemento nuevo. Así que cambiar la clave es una manera de reiniciar un componente: un formulario con el id del producto como clave empieza vacío para cada producto nuevo.
Varios elementos por fila. Si un producto son dos elementos (<dt> y <dd>), la clave va en el envoltorio: <Fragment key={p.id}>…</Fragment>. La forma corta <>…</> no admite clave.
Componentes de clase. El código antiguo de React tiene los componentes como clases (class ProductList extends Component). Las claves funcionan en ellos igual.
toSorted. products.toSorted((a, b) => a.price - b.price) devuelve una copia ordenada y no toca el original. Está en los navegadores desde 2023.
Por qué el campo es «no controlado». Aquí el número del campo está solo en el DOM. En el módulo «Estado» el campo pasa a ser controlado: el número está en los datos y los sigue. Entonces la clave sigue importando: por el foco, el cursor y el estado que el componente guarda dentro de sí mismo.
Ejercicio
El carrito
El estado y el botón «Ordenar por precio» están listos. Haz la fila de cada producto: ProductCard y un botón «Quitar» cuyo nombre para un lector de pantalla sea «Quitar Pan blanco». La comprobación escribe cantidades, ordena, quita el pan y mira que cada número se quede con su producto.
Consola
Solución
App.jsximport { useState } from 'react';
import ProductCard from './ProductCard';
const PRODUCTS = [
{ id: 3, name: 'Queso blanco', price: 7.9 },
{ id: 1, name: 'Pan blanco', price: 1.2 },
{ id: 2, name: 'Leche fresca', price: 1.6 },
];
export default function App() {
// El estado (módulo siguiente): la lista que cambian los botones.
const [items, setItems] = useState(PRODUCTS);
const remove = (id) => setItems(items.filter((p) => p.id !== id));
const sortByPrice = () => setItems([...items].sort((a, b) => a.price - b.price));
return (
<main>
<h1>El carrito ({items.length})</h1>
<ul>
{items.map((p) => (
<li key={p.id}>
<ProductCard product={p} />
<button type="button" aria-label={`Quitar ${p.name}`} onClick={() => remove(p.id)}>Quitar</button>
</li>
))}
</ul>
<button type="button" onClick={sortByPrice}>Ordenar por precio</button>
</main>
);
}
ProductCard.jsxconst money = (x) => x.toFixed(2).replace('.', ',') + ' €';
export default function ProductCard({ product }) {
return (
<article>
<h2>{product.name}</h2>
<p>{money(product.price)}</p>
<label>
Cantidad <input type="number" min="1" defaultValue="1" aria-label={`Cantidad: ${product.name}`} />
</label>
</article>
);
}
style.cssbody { font-family: system-ui, sans-serif; margin: 16px; }
h1 { font-size: 22px; margin: 0 0 12px; }
ul { list-style: none; padding: 0; margin: 0 0 12px; display: grid; gap: 8px; }
li { display: flex; align-items: center; gap: 12px; border: 1px solid #ddd; border-radius: 8px; padding: 8px 12px; }
article { display: flex; align-items: center; gap: 12px; flex: 1; }
h2 { font-size: 16px; margin: 0; flex: 1; }
p { margin: 0; }
input { width: 4em; font-size: 15px; }
button { font-size: 15px; padding: 4px 12px; }
La fila es un <li> con la clave p.id, ProductCard y un botón. El botón llama a remove(p.id), y su nombre para un lector de pantalla viene de aria-label. Con el índice como clave, al ordenar los números se mueven: el queso estaba primero, así que el 4 se queda en la primera fila, que ahora es el pan. Con Math.random() cada ordenación pone los campos otra vez a 1.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Eventos · Una lista a partir de datos · El primer componente · Props: datos desde arriba
- Continúa en: Condiciones: vacío, error, cargando · Estado · Actualizar objetos y arrays · Estado derivado
- Ejercicios sobre el tema: Componentes