Renderizar colecciones con map: del array a la UI
En React, la forma más común de pintar una lista es transformar un array de datos en un array de elementos React usando map. La idea es que cada elemento renderizado represente un ítem de tu colección.
const products = [
{ id: 'p1', name: 'Café' },
{ id: 'p2', name: 'Té' },
];
function ProductList() {
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}Fíjate en key: es un atributo especial que React usa para identificar de forma estable cada elemento de la lista entre renders.
Qué es key y por qué importa: identidad, reconciliación y rendimiento
Cuando el estado cambia, React vuelve a renderizar y luego compara el árbol anterior con el nuevo para aplicar el mínimo de cambios al DOM (proceso conocido como reconciliación). En listas, React necesita saber qué elemento “nuevo” corresponde a cuál “viejo”.
keyes identidad: le dice a React “este elemento es el mismo ítem lógico que antes”.- Sin una identidad estable, React puede reutilizar nodos/instancias equivocadas, mezclando estado interno (por ejemplo, el valor de un input no controlado o un estado visual local).
- Con keys correctas, React puede mover, insertar o eliminar ítems con menos trabajo y sin efectos secundarios extraños.
Regla práctica: la key debe ser única entre hermanos y estable en el tiempo para ese ítem.
Por qué NO usar índices como key en listas dinámicas
Usar el índice (key={index}) parece funcionar al principio, pero falla cuando la lista cambia por inserción, eliminación o reordenamiento. El índice no identifica al ítem: identifica su posición. Si la posición cambia, la identidad cambia, y React “cree” que son otros elementos.
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
Ejemplo 1: inserción al inicio y inputs que “cambian de fila”
Este ejemplo usa inputs no controlados (sin value), donde el DOM mantiene el texto escrito. Con keys incorrectas, React puede reutilizar el nodo del input para otro ítem.
import { useState } from 'react';
const initial = [
{ id: 'a', label: 'Ana' },
{ id: 'b', label: 'Beto' },
{ id: 'c', label: 'Carla' },
];
export function BadKeys_Insert() {
const [people, setPeople] = useState(initial);
function addAtTop() {
const newPerson = { id: crypto.randomUUID(), label: 'Nueva' };
setPeople([newPerson, ...people]);
}
return (
<div>
<button onClick={addAtTop}>Insertar arriba</button>
<ul>
{people.map((p, index) => (
<li key={index}>
<span>{p.label}: </span>
<input placeholder="Escribe algo" />
</li>
))}
</ul>
</div>
);
}Paso a paso para reproducir el problema:
- Escribe “hola” en el input de “Beto”.
- Pulsa “Insertar arriba”.
- Observa que “hola” puede aparecer ahora en otra fila (porque el input DOM se reutilizó para el ítem que quedó en esa posición).
Arreglo: usar una key estable basada en el id del ítem.
{people.map((p) => (
<li key={p.id}>
<span>{p.label}: </span>
<input placeholder="Escribe algo" />
</li>
))}Ejemplo 2: eliminación y estado visual que se mezcla
Imagina que cada fila tiene un estado visual local (por ejemplo, “expandido/colapsado”). Si eliminas un elemento en medio y usas índices como key, ese estado puede “saltar” a la fila siguiente.
import { useState } from 'react';
function Row({ label }) {
const [expanded, setExpanded] = useState(false);
return (
<div style={{ padding: 8, border: '1px solid #ddd' }}>
<button onClick={() => setExpanded((v) => !v)}>
{expanded ? 'Contraer' : 'Expandir'}
</button>
<strong style={{ marginLeft: 8 }}>{label}</strong>
{expanded && <div>Detalles de {label}</div>}
</div>
);
}
export function BadKeys_Delete() {
const [items, setItems] = useState([
{ id: '1', label: 'Fila 1' },
{ id: '2', label: 'Fila 2' },
{ id: '3', label: 'Fila 3' },
]);
function removeSecond() {
setItems((prev) => prev.filter((x) => x.id !== '2'));
}
return (
<div>
<button onClick={removeSecond}>Eliminar Fila 2</button>
<div style={{ display: 'grid', gap: 8, marginTop: 12 }}>
{items.map((it, index) => (
<Row key={index} label={it.label} />
))}
</div>
</div>
);
}Paso a paso:
- Expande “Fila 3”.
- Elimina “Fila 2”.
- Es posible que ahora “Fila 3” pierda/mezcle su estado, o que el estado expandido aparezca asociado a otra fila, porque React reasignó instancias según posición.
Arreglo: key por id.
{items.map((it) => (
<Row key={it.id} label={it.label} />
))}Ejemplo 3: reordenamiento y selección que apunta al ítem equivocado
En listas reordenables (por ejemplo, ordenar alfabéticamente), el índice como key hace que React “pegue” el componente a la posición, no al ítem. Si el componente tiene estado local o contiene inputs no controlados, verás inconsistencias.
import { useMemo, useState } from 'react';
function SortableRow({ item, selected, onToggle }) {
return (
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={selected} onChange={() => onToggle(item.id)} />
<span>{item.label}</span>
</div>
);
}
export function BadKeys_Reorder() {
const [items, setItems] = useState([
{ id: 'a', label: 'Zeta' },
{ id: 'b', label: 'Alfa' },
{ id: 'c', label: 'Beta' },
]);
const [selectedIds, setSelectedIds] = useState(() => new Set());
const sorted = useMemo(() => {
return [...items].sort((x, y) => x.label.localeCompare(y.label));
}, [items]);
function toggle(id) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function shuffle() {
setItems((prev) => [...prev].reverse());
}
return (
<div>
<button onClick={shuffle}>Reordenar</button>
<div style={{ display: 'grid', gap: 8, marginTop: 12 }}>
{sorted.map((item, index) => (
<SortableRow
key={index}
item={item}
selected={selectedIds.has(item.id)}
onToggle={toggle}
/>
))}
</div>
</div>
);
}Aunque aquí el checkbox es controlado (depende de selectedIds), en cuanto añadas estado local por fila (hover persistente, edición inline con input no controlado, animaciones, etc.), el reordenamiento con keys por índice tenderá a producir “traspasos” de estado entre filas.
Guía práctica: cómo elegir buenas keys
Paso 1: identifica el “id” real del dominio
La mejor key suele ser un identificador persistente del dato: product.id, userId, slug, etc. Debe mantenerse igual aunque cambie el orden.
- Bien:
key={item.id} - Bien:
key={item.slug}si es único y estable - Mal:
key={index}si la lista puede cambiar - Mal:
key={Math.random()}okey={crypto.randomUUID()}generado en render (cambia en cada render, fuerza remount y destruye estado)
Paso 2: si no tienes id, créalo al ingresar los datos (no al renderizar)
Si tus ítems vienen sin id (por ejemplo, una lista de tareas creada en el cliente), asigna un id al momento de crear el ítem y guárdalo en el estado.
import { useState } from 'react';
export function TodoList() {
const [todos, setTodos] = useState([]);
const [text, setText] = useState('');
function addTodo(e) {
e.preventDefault();
const trimmed = text.trim();
if (!trimmed) return;
setTodos((prev) => [
...prev,
{ id: crypto.randomUUID(), text: trimmed, done: false },
]);
setText('');
}
return (
<div>
<form onSubmit={addTodo}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button>Añadir</button>
</form>
<ul>
{todos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
</div>
);
}Paso 3: en listas anidadas, la key debe ser única en su nivel
La unicidad de key se evalúa entre hermanos del mismo array. Si renderizas sublistas, cada sublista necesita sus propias keys.
{groups.map((g) => (
<section key={g.id}>
<h3>{g.name}</h3>
<ul>
{g.items.map((it) => (
<li key={it.id}>{it.label}</li>
))}
</ul>
</section>
))}Paso 4: cuándo el índice puede ser aceptable
Solo considera key={index} si se cumplen todas estas condiciones:
- La lista es estática (no se inserta, elimina ni reordena).
- Los ítems no tienen estado local ni contienen inputs no controlados.
- No hay animaciones/transiciones por ítem que dependan de montaje/desmontaje.
En la práctica, muchas listas terminan siendo dinámicas, así que es más seguro diseñar con ids desde el inicio.
Estructurar componentes de ítem: separar presentación y lógica de selección
Además de elegir buenas keys, una lista suele necesitar interacción (seleccionar, marcar, editar). Una forma mantenible es separar:
- Componente de presentación: recibe datos y callbacks, no decide reglas.
- Contenedor/lista: mantiene la estructura de datos (por ejemplo, ids seleccionados) y define la lógica.
Ejemplo: lista seleccionable con keys estables
function ItemRowView({ label, selected, onToggle }) {
return (
<div
onClick={onToggle}
style={{
padding: 10,
border: '1px solid #ddd',
background: selected ? '#e8f0ff' : 'white',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
}}
role="button"
tabIndex={0}
>
<span>{label}</span>
<span>{selected ? 'Seleccionado' : ''}</span>
</div>
);
}
export function SelectableList({ items }) {
const [selectedIds, setSelectedIds] = useState(() => new Set());
function toggle(id) {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<div style={{ display: 'grid', gap: 8 }}>
{items.map((it) => (
<ItemRowView
key={it.id}
label={it.label}
selected={selectedIds.has(it.id)}
onToggle={() => toggle(it.id)}
/>
))}
</div>
);
}Ventajas de esta estructura:
- La identidad visual de cada fila queda anclada a
it.id(key estable). - La selección se modela por ids, así que sobrevive a reordenamientos e inserciones.
ItemRowViewes fácil de reutilizar y testear: solo pinta según props.
Checklist rápido para depurar problemas de listas
| Síntoma | Causa probable | Qué revisar |
|---|---|---|
| Texto escrito en un input aparece en otra fila tras insertar/eliminar | Keys por índice o keys inestables | Usa key={item.id} y evita generar keys en render |
| Filas “reciclan” estado (expandido, hover persistente, edición) | Identidad ligada a posición | Key estable por ítem; evita index en listas dinámicas |
| Rendimiento pobre en listas grandes al reordenar | React no puede reconciliar eficientemente | Keys estables y componentes de ítem bien delimitados |
| Animaciones se reinician o parpadean | Remount por key cambiante | No uses Math.random()/UUID en render |