Listas y claves
Renderiza colecciones dinámicas de forma eficiente dándole a React una identidad estable para cada elemento.
Busca en todas las páginas de la documentación
Renderiza colecciones dinámicas de forma eficiente dándole a React una identidad estable para cada elemento.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
key estable y única basada en un ID generado, no en el índice del arraymap para alternar, filter para eliminar, spread para agregarArray.prototype.map() dentro de JSX devuelve un array de elementos - React renderiza cada unokey le indica a React qué ítem es cuál entre re-renderizados, para que pueda emparejar los elementos antiguos y nuevos| Prop | Tipo | Descripción |
|---|---|---|
key | string o number | Identidad estable para cada ítem de la lista - debe ser única entre hermanos |
| 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 |
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.
// 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>
);
}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={\${item.type}-${item.id}`}`.
Clave en el elemento equivocado - Colocar key en el <span> interno en lugar del elemento más externo devuelto por map no hace nada. Solución: coloca siempre key en 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.memo y estabiliza los callbacks con useCallback, 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 === 0 con un mensaje de marcador de posición.
| 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 |
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:
.map() anidados: secciones, temas, puntos, reflejando la jerarquía del modelo de datoskey={*.id} con IDs de base de datos estables (UUIDs), nunca índices de array<>...</> (Fragment) envuelve listas hermanas sin agregar nodos extra al DOM{isLoaded && ...} garantiza que los puntos solo se rendericen después de que se complete la carga diferida de esa secciónsection._count?.sectionPoints ?? section.sectionPoints?.length ?? 0 usa coalescencia nula para gestionar de forma segura dos formas de datos diferentes (Prisma _count vs arrays poblados).map() puede perjudicar la legibilidad. Considera extraer subcomponentes TopicList y PointList si creceLas 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.
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).
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}`}.
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>;setItems(prev => [...prev, newItem])setItems(prev => prev.filter(i => i.id !== id))setItems(prev => prev.map(i => i.id === id ? { ...i, done: true } : i))Siempre en el elemento más externo devuelto por el callback de .map(). Colocarla en un elemento hijo interno no tiene efecto.
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.
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.
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.
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} />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.
Revisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥