Listas y claves
Renderiza colecciones dinámicas de forma eficiente dándole a React una identidad estable para cada elemento.
Receta
Tarjeta de referencia rápida — lista para copiar y pegar.
// Renderizado básico de listas
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
// Con claves en Fragment (múltiples elementos por ítem)
import { Fragment } from "react";
{entries.map(entry => (
<Fragment key={entry.id}>
<dt>{entry.term}</dt>
<dd>{entry.definition}</dd>
</Fragment>
))}
// Filtrado + mapeo
{users
.filter(u => u.isActive)
.map(u => <UserCard key={u.id} user={u} />)}Cuándo recurrir a esto: cada vez que renderices un array de datos — ítems de tareas pendientes, resultados de búsqueda, filas de tabla, enlaces de navegación.
Ejemplo funcional
"use client";
import { useState } from "react";
interface Todo {
id: string;
text: string;
done: boolean;
}
let nextId = 0;
function createId() {
return `todo-${++nextId}-${Date.now()}`;
}
export function TodoList() {
const [todos, setTodos] = useState<Todo[]>([]);
const [draft, setDraft] = useState("");
function addTodo() {
const text = draft.trim();
if (!text) return;
setTodos(prev => [...prev, { id: createId(), text, done: false }]);
setDraft("");
}
function toggleTodo(id: string) {
setTodos(prev =>
prev.map(t => (t.id === id ? { ...t, done: !t.done } : t))
);
}
function removeTodo(id: string) {
setTodos(prev => prev.filter(t => t.id !== id));
}
return (
<div className="max-w-sm space-y-3 rounded border p-4">
<form
onSubmit={e => {
e.preventDefault();
addTodo();
}}
className="flex gap-2"
>
<input
value={draft}
onChange={e => setDraft(e.target.value)}
placeholder="Agregar una tarea..."
className="flex-1 rounded border px-3 py-1"
/>
<button type="submit" className="rounded bg-blue-600 px-3 py-1 text-white">
Agregar
</button>
</form>
{todos.length === 0 && (
<p className="text-sm text-gray-400">Aún no hay tareas.</p>
)}
<ul className="space-y-1">
{todos.map(todo => (
<li key={todo.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
<span className={todo.done ? "flex-1 line-through text-gray-400" : "flex-1"}>
{todo.text}
</span>
<button
onClick={() => removeTodo(todo.id)}
className="text-xs text-red-500"
>
Eliminar
</button>
</li>
))}
</ul>
<p className="text-xs text-gray-500">
{todos.filter(t => !t.done).length} pendientes
</p>
</div>
);
}Qué demuestra esto:
keyestable y única basada en un ID generado, no en el índice del array- Actualizaciones inmutables del array:
mappara alternar,filterpara eliminar, spread para agregar - Valor derivado (conteo de pendientes) calculado en línea a partir del array
- Estado vacío gestionado con un condicional
Análisis en profundidad
Cómo funciona
Array.prototype.map()dentro de JSX devuelve un array de elementos — React renderiza cada uno- La prop
keyle indica a React qué ítem es cuál entre re-renderizados, para que pueda emparejar los elementos antiguos y nuevos - Cuando la clave de un ítem permanece igual, React actualiza el nodo del DOM existente (preservando el estado, como el foco y los valores de los inputs)
- Cuando una clave es nueva, React monta un nuevo componente. Cuando una clave desaparece, React desmonta el antiguo
- Las claves solo necesitan ser únicas entre hermanos — no globalmente
Parámetros y valores de retorno
| Prop | Tipo | Descripción |
|---|---|---|
key | string o number | Identidad estable para cada ítem de la lista — debe ser única entre hermanos |
Qué hace una buena clave
| Fuente | ¿Buena? | Por qué |
|---|---|---|
| ID de base de datos | Sí | Estable y única por definición |
| UUID / nanoid | Sí | Única, sobrevive al reordenamiento |
item.slug | Sí | Estable si los slugs no cambian |
| Índice del array | A veces | Solo es seguro para listas estáticas que nunca se reordenan, filtran ni insertan |
Math.random() | No | Crea una clave nueva en cada render — fuerza el remontaje cada vez |
Variaciones
Listas anidadas:
{categories.map(cat => (
<section key={cat.id}>
<h2>{cat.name}</h2>
<ul>
{cat.items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</section>
))}Listas ordenadas y filtradas:
const visibleItems = useMemo(
() =>
items
.filter(item => item.name.toLowerCase().includes(query.toLowerCase()))
.sort((a, b) => a.name.localeCompare(b.name)),
[items, query]
);
return (
<ul>
{visibleItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);Reiniciar el estado de un componente con key:
// Cambiar la clave fuerza a React a desmontar y volver a montar el componente
<PlayerProfile key={currentPlayerId} playerId={currentPlayerId} />Búsqueda con múltiples criterios (filtrar por consulta + categoría + estado):
const visible = useMemo(() => {
const q = query.trim().toLowerCase();
return products.filter(p =>
(!q || p.name.toLowerCase().includes(q)) &&
(category === "all" || p.category === category) &&
(!inStockOnly || p.stock > 0)
);
}, [products, query, category, inStockOnly]);
return (
<ul>
{visible.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);Encadena las condiciones dentro de un solo filter y realiza cortocircuito con los centinelas !q / "all", de modo que cada campo pueda tener independientemente "cualquier valor". useMemo omite el trabajo cuando cambia estado no relacionado.
Agrupar con reduce y luego renderizar secciones:
const byStatus = useMemo(() => {
return tasks.reduce<Record<string, Task[]>>((acc, task) => {
(acc[task.status] ??= []).push(task);
return acc;
}, {});
}, [tasks]);
return (
<>
{Object.entries(byStatus).map(([status, group]) => (
<section key={status}>
<h3>{status} ({group.length})</h3>
<ul>
{group.map(t => <li key={t.id}>{t.title}</li>)}
</ul>
</section>
))}
</>
);Usa reduce para agrupar los ítems por un campo, y luego Object.entries(...).map(...) para renderizar cada grupo. El operador ??= crea el array de forma perezosa en la primera inserción.
Aplanar arrays anidados con flatMap:
// Convierte [{ author, posts: [...] }, ...] en una única lista plana de posts
const allPosts = threads.flatMap(thread =>
thread.posts.map(post => ({ ...post, author: thread.author }))
);
return (
<ul>
{allPosts.map(p => (
<li key={p.id}>
<strong>{p.author}:</strong> {p.body}
</li>
))}
</ul>
);flatMap es .map().flat() en una sola pasada — úsalo cuando necesites expandir cada ítem de entrada en cero o más salidas mientras inyectas datos de nivel superior en cada hijo.
Despachar a diferentes componentes según el tipo de ítem:
type FeedItem =
| { type: "post"; id: string; body: string }
| { type: "ad"; id: string; campaignId: string }
| { type: "divider"; id: string };
return (
<ul>
{feed.map(item => {
switch (item.type) {
case "post": return <PostCard key={item.id} post={item} />;
case "ad": return <AdSlot key={item.id} campaignId={item.campaignId} />;
case "divider": return <hr key={item.id} />;
}
})}
</ul>
);Las uniones discriminadas permiten que un solo .map() renderice un feed heterogéneo — TypeScript acota item dentro de cada case, de modo que item.body e item.campaignId solo son accesibles en la rama correcta.
Paginación con slice:
const PAGE_SIZE = 20;
const pageItems = items.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<>
<ul>
{pageItems.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
<button onClick={() => setPage(p => p + 1)} disabled={(page + 1) * PAGE_SIZE >= items.length}>
Siguiente
</button>
</>
);slice es inmutable y económico — úsalo para paginación en el cliente, "mostrar los primeros 5" o mostrar una vista previa de una lista larga.
Ordenamiento inmutable con toSorted (ES2023):
// toSorted devuelve un nuevo array — no hace falta copiar primero
const ranked = players.toSorted((a, b) => b.score - a.score);
return (
<ol>
{ranked.map((p, i) => (
<li key={p.id}>#{i + 1} {p.name} — {p.score}</li>
))}
</ol>
);.sort() muta en el sitio, lo que rompe la invalidación de useMemo y puede corromper las props. Prefiere toSorted() (o [...arr].sort() en runtimes más antiguos). El mismo patrón existe para toReversed y toSpliced.
Eliminar duplicados antes de mapear:
const uniqueTags = Array.from(new Set(posts.flatMap(p => p.tags)));
return (
<div className="flex gap-2">
{uniqueTags.map(tag => (
<button key={tag} onClick={() => toggleFilter(tag)}>#{tag}</button>
))}
</div>
);new Set(...) colapsa los duplicados; Array.from(...) (o [...set]) lo vuelve a convertir en un array sobre el que puedes usar .map(). Funciona para primitivos — para objetos, elimina duplicados por ID primero.
Notas de TypeScript
// Callback de map tipado
interface Product {
id: string;
name: string;
price: number;
}
function ProductList({ products }: { products: Product[] }) {
return (
<ul>
{products.map(({ id, name, price }) => (
<li key={id}>
{name} — ${price.toFixed(2)}
</li>
))}
</ul>
);
}Trampas comunes
-
Claves de índice con listas reordenables — Usar
key={index}en una lista ordenable o filtrable hace que React reutilice los nodos del DOM equivocados, lo que provoca valores de input obsoletos y animaciones rotas. Solución: usa un ID único y estable de tus datos. -
Claves duplicadas — Dos hermanos con la misma clave provocan comportamiento impredecible — React descarta uno silenciosamente. Solución: asegúrate de que las claves sean únicas. Si tus datos tienen duplicados, combina campos:
key={\$-$`}`. -
Clave en el elemento equivocado — Colocar
keyen el<span>interno en lugar del elemento más externo devuelto pormapno hace nada. Solución: coloca siemprekeyen el elemento que devuelve directamente el callback de.map(). -
Re-renderizados costosos de listas — Una lista grande que se re-renderiza en cada render del padre provoca tirones. Solución: memoriza los ítems de la lista con
React.memoy estabiliza los callbacks conuseCallback, o virtualiza con@tanstack/react-virtual. -
Olvidar el estado vacío — Un array vacío no renderiza nada, lo que puede parecer una UI rota. Solución: gestiona siempre
items.length === 0con un mensaje de marcador de posición.
Alternativas
| Alternativa | Úsala cuando | No la uses cuando |
|---|---|---|
@tanstack/react-virtual | Renderizas miles de ítems (lista virtualizada) | Es una lista corta (menos de 100 ítems) |
React.Children.map | Iteras sobre los elementos de la prop children | Tienes arrays de datos — usa .map() normal |
Grid CSS repeat() | El diseño es pura repetición visual sin datos dinámicos | Cada ítem tiene datos o estado únicos |
Ejemplo del mundo real
De una aplicación SaaS de producción con Next.js 15 / React 19 (SystemsArchitect.io).
// Ejemplo de producción: renderizado de lista anidada de tres niveles
// Archivo: src/components/services/content-display.tsx
{content.sections.map((section) => {
const isLoaded = loadedSections.has(section.id);
const pointCount = section._count?.sectionPoints ?? section.sectionPoints?.length ?? 0;
return (
<AccordionItem key={section.id} value={`section-${section.id}`} className="border rounded-lg">
<AccordionContent className="px-6 pb-6">
{isLoaded && section.sectionPoints && section.topics && (
<>
{section.sectionPoints.map((point) => (
<PointCard
key={point.id}
point={point}
serviceSlug={content.slug}
sectionSlug={section.sectionId}
/>
))}
{section.topics.map((topic) => (
<div key={topic.id} className="mb-6 last:mb-0">
<h4 className="text-xl font-medium">{topic.topicTitle}</h4>
<div className="ml-6 space-y-3">
{topic.topicPoints.map((point) => (
<PointCard key={point.id} point={point} serviceSlug={content.slug} sectionSlug={section.sectionId} />
))}
</div>
</div>
))}
</>
)}
</AccordionContent>
</AccordionItem>
);
})}Qué demuestra esto en producción:
- Tres niveles de
.map()anidados: secciones, temas, puntos, reflejando la jerarquía del modelo de datos - Cada elemento mapeado usa
key={*.id}con IDs de base de datos estables (UUIDs), nunca índices de array <>...</>(Fragment) envuelve listas hermanas sin agregar nodos extra al DOM- El condicional
{isLoaded && ...}garantiza que los puntos solo se rendericen después de que se complete la carga diferida de esa sección section._count?.sectionPoints ?? section.sectionPoints?.length ?? 0usa coalescencia nula para gestionar de forma segura dos formas de datos diferentes (Prisma _count vs arrays poblados)- El anidamiento profundo de llamadas a
.map()puede perjudicar la legibilidad. Considera extraer subcomponentesTopicListyPointListsi crece
Preguntas frecuentes
¿Por qué React necesita una prop key en los ítems de la lista?
Las claves le indican a React qué ítem es cuál entre re-renderizados. Sin claves estables, React no puede emparejar correctamente los elementos antiguos y nuevos, lo que provoca pérdida de estado, animaciones rotas y reutilización incorrecta del DOM.
¿Alguna vez está bien usar el índice del array como clave?
Solo para listas estáticas que nunca se reordenan, filtran ni tienen ítems insertados/eliminados. Para cualquier lista dinámica, usa un ID único y estable de tus datos (ID de base de datos, UUID o slug).
¿Qué ocurre si dos hermanos tienen la misma clave?
React descarta uno de ellos silenciosamente, lo que provoca un comportamiento impredecible. Asegúrate siempre de que las claves sean únicas entre hermanos. Combina campos si es necesario: key={`${item.type}-${item.id}`}.
¿Cómo renderizo una lista con filtro y ordenamiento?
Encadena .filter() y .sort() antes de .map(). Envuelve en useMemo si la lista es grande:
const visible = useMemo(
() => items.filter(i => i.active).sort((a, b) => a.name.localeCompare(b.name)),
[items]
);
return <ul>{visible.map(i => <li key={i.id}>{i.name}</li>)}</ul>;¿Cómo agrego, elimino o actualizo ítems de una lista de forma inmutable?
- Agregar:
setItems(prev => [...prev, newItem]) - Eliminar:
setItems(prev => prev.filter(i => i.id !== id)) - Actualizar:
setItems(prev => prev.map(i => i.id === id ? { ...i, done: true } : i))
¿Dónde debe ir la prop key — en el elemento externo o interno?
Siempre en el elemento más externo devuelto por el callback de .map(). Colocarla en un elemento hijo interno no tiene efecto.
¿Por qué Math.random() es una mala clave?
Genera un valor nuevo en cada render, por lo que React trata cada ítem como nuevo — desmontando y volviendo a montar todos los componentes cada vez. Esto destruye el estado y arruina el rendimiento.
¿Cómo gestiono una lista vacía?
Comprueba items.length === 0 y renderiza un mensaje de marcador de posición. Un array vacío no renderiza nada, lo que puede parecer una UI rota.
¿Cómo uso Fragment con una clave en una lista?
Importa Fragment desde React y usa la sintaxis con nombre:
import { Fragment } from "react";
{items.map(item => (
<Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</Fragment>
))}La sintaxis corta <> no admite claves.
¿Cómo puedo forzar a un componente a reiniciar su estado usando key?
Cambia la key a un nuevo valor. React desmonta el componente antiguo y monta uno nuevo con el estado inicial:
<PlayerProfile key={currentPlayerId} playerId={currentPlayerId} />¿Cuándo debería virtualizar una lista?
Cuando renderizar más de unos pocos cientos de ítems provoca tirones visibles. Usa @tanstack/react-virtual para renderizar solo los ítems que están actualmente en el viewport, manteniendo pequeño el tamaño del DOM.
Relacionado
- Renderizado condicional — filtrado y estados vacíos
- Componentes — extraer ítems de lista en sus propios componentes
- useState — gestionar datos de lista como estado