Listas e Chaves
Renderize coleções dinâmicas de forma eficiente, dando ao React uma identidade estável para cada item.
Busque em todas as páginas da documentação
Renderize coleções dinâmicas de forma eficiente, dando ao React uma identidade estável para cada item.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida - pronto para copiar e colar.
// Renderização básica de lista
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
// Com chaves de Fragment (múltiplos elementos por item)
import { Fragment } from "react";
{entries.map(entry => (
<Fragment key={entry.id}>
<dt>{entry.term}</dt>
<dd>{entry.definition}</dd>
</Fragment>
))}
// Filtrando + mapeando
{users
.filter(u => u.isActive)
.map(u => <UserCard key={u.id} user={u} />)}Quando usar isso: Sempre que você renderizar um array de dados - itens de tarefas, resultados de pesquisa, linhas de tabela, links de navegação.
"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="Add a task..."
className="flex-1 rounded border px-3 py-1"
/>
<button type="submit" className="rounded bg-blue-600 px-3 py-1 text-white">
Add
</button>
</form>
{todos.length === 0 && (
<p className="text-sm text-gray-400">No tasks yet.</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"
>
Remove
</button>
</li>
))}
</ul>
<p className="text-xs text-gray-500">
{todos.filter(t => !t.done).length} remaining
</p>
</div>
);
}O que isso demonstra:
key estável e única baseada em um ID gerado, não no índice do arraymap para alternar, filter para remover, spread para adicionarArray.prototype.map() dentro do JSX retorna um array de elementos - o React renderiza cada umkey informa ao React qual item é qual entre as re-renderizações, para que ele possa corresponder elementos antigos e novos| Prop | Tipo | Descrição |
|---|---|---|
key | string or number | Identidade estável para cada item da lista - deve ser única entre irmãos |
| Fonte | Bom? | Por quê |
|---|---|---|
| ID do Banco de Dados | Sim | Estável e único por definição |
| UUID / nanoid | Sim | Único, sobrevive à reordenação |
item.slug | Sim | Estável se os slugs não mudarem |
| Índice do Array | Às vezes | Só é seguro para listas estáticas que nunca reordenam, filtram ou inserem |
Math.random() | Não | Cria uma nova chave a cada renderização - força a remontagem a cada vez |
Listas aninhadas:
{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 e 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>
);Redefinindo o estado do componente com chave:
// Mudar a chave força o React a desmontar e remontar o componente
<PlayerProfile key={currentPlayerId} playerId={currentPlayerId} />Pesquisa com múltiplos critérios (filtrar por consulta + categoria + status):
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>
);Encadeie as condições dentro de um único filter e use curto-circuito com sentinelas !q / "all" para que cada campo possa ser independentemente "qualquer valor". useMemo pula o trabalho quando o estado não relacionado muda.
Agrupando com reduce, depois renderizando seções:
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>
))}
</>
);Use reduce para agrupar itens por um campo, depois Object.entries(...).map(...) para renderizar cada grupo. O operador ??= cria o array preguiçosamente na primeira inserção.
Achatando arrays aninhados com flatMap:
// Transforma [{ author, posts: [...] }, ...] em uma única lista achatada 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 é .map().flat() em uma única passagem - use-o quando precisar expandir cada item de entrada em zero ou mais saídas, injetando dados do nível pai em cada filho.
Despachando para componentes diferentes por tipo de item:
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>
);Uniões discriminadas permitem que um .map() renderize um feed heterogêneo - TypeScript restringe item dentro de cada case, então item.body e item.campaignId só são acessíveis no ramo correto.
Paginação com 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}>
Next
</button>
</>
);slice é imutável e barato - use-o para paginação do lado do cliente, "mostrar os 5 primeiros" ou mostrar uma prévia de uma lista longa.
Ordenação imutável com toSorted (ES2023):
// toSorted retorna um novo array - não é necessário copiar primeiro
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() modifica no local, o que quebra a invalidação do useMemo e pode corromper props. Prefira toSorted() (ou [...arr].sort() em runtimes mais antigos). O mesmo padrão existe para toReversed e toSpliced.
Deduplicando 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 duplicatas; Array.from(...) (ou [...set]) o transforma de volta em um array que você pode .map(). Funciona para primitivos - para objetos, deduplique por ID primeiro.
// 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>
);
}Chaves de índice com listas reordenáveis - Usar key={index} em uma lista ordenável ou filtrável faz com que o React reutilize nós DOM incorretos, levando a valores de entrada desatualizados e animações quebradas. Correção: Use um ID único e estável dos seus dados.
Chaves duplicadas - Dois irmãos com a mesma chave causam comportamento imprevisível - o React descarta silenciosamente um deles. Correção: Garanta que as chaves sejam únicas. Se seus dados tiverem duplicatas, combine campos: key={\${item.type}-${item.id}`}`.
Chave no elemento errado - Colocar key no <span> interno em vez do elemento mais externo retornado por map não faz nada. Correção: Sempre coloque key no elemento retornado imediatamente pelo callback .map().
Renderizações de lista caras - Uma lista grande que re-renderiza a cada renderização pai causa lentidão. Correção: Memorize os itens da lista com React.memo e estabilize os callbacks com useCallback, ou virtualize com @tanstack/react-virtual.
Esquecer o estado vazio - Um array vazio não renderiza nada, o que pode parecer uma interface quebrada. Correção: Sempre trate items.length === 0 com uma mensagem de placeholder.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
@tanstack/react-virtual | Renderizar milhares de itens (lista virtualizada) | Uma lista curta (menos de 100 itens) |
React.Children.map | Iterar sobre elementos da prop children | Você tem arrays de dados - use .map() simples |
Grid CSS repeat() | O layout é puramente repetição visual sem dados dinâmicos | Cada item tem dados ou estado únicos |
De uma aplicação SaaS de produção Next.js 15 / React 19 (SystemsArchitect.io).
// Exemplo de produção: Renderização de lista aninhada de três níveis
// Arquivo: 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>
);
})}O que isso demonstra em produção:
.map() aninhados: seções, tópicos, pontos, refletindo a hierarquia do modelo de dadoskey={*.id} com IDs de banco de dados estáveis (UUIDs), nunca índices de array<>...</> (Fragment) envolve listas irmãs sem adicionar nós DOM extras{isLoaded && ...} garante que os pontos só renderizem após a conclusão do carregamento preguiçoso para essa seçãosection._count?.sectionPoints ?? section.sectionPoints?.length ?? 0 usa coalescência nula para lidar com segurança com duas formas de dados diferentes (contagem _count do Prisma vs arrays preenchidos).map() pode prejudicar a legibilidade. Considere extrair subcomponentes TopicList e PointList se crescerChaves informam ao React qual item é qual entre as re-renderizações. Sem chaves estáveis, o React não consegue corresponder corretamente elementos antigos e novos, levando a estado perdido, animações quebradas e reutilização incorreta do DOM.
Apenas para listas estáticas que nunca reordenam, filtram ou têm itens inseridos/removidos. Para qualquer lista dinâmica, use um ID único e estável dos seus dados (ID do banco de dados, UUID ou slug).
O React descarta silenciosamente um deles, causando comportamento imprevisível. Sempre garanta que as chaves sejam únicas entre irmãos. Combine campos, se necessário: key={`${item.type}-${item.id}`}.
Encadeie .filter() e .sort() antes de .map(). Envolva em useMemo se a lista for 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))Sempre no elemento mais externo retornado pelo callback .map(). Colocá-lo em um elemento filho interno não tem efeito.
Ele gera um novo valor a cada renderização, então o React trata cada item como novo - desmontando e remontando todos os componentes a cada vez. Isso destrói o estado e mata o desempenho.
Verifique items.length === 0 e renderize uma mensagem de placeholder. Um array vazio não renderiza nada, o que pode parecer uma interface quebrada.
Importe Fragment do React e use a sintaxe nomeada:
import { Fragment } from "react";
{items.map(item => (
<Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.definition}</dd>
</Fragment>
))}A sintaxe curta <> não suporta chaves.
Mude a key para um novo valor. O React desmonta o componente antigo e monta um novo com o estado inicial:
<PlayerProfile key={currentPlayerId} playerId={currentPlayerId} />Ao renderizar mais de algumas centenas de itens causa lentidão visível. Use @tanstack/react-virtual para renderizar apenas os itens atualmente na viewport, mantendo o tamanho do DOM pequeno.
Revisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥