Each child in a list should have a unique key prop
O aviso de key no console do React: por que ele aparece, o que quebra de verdade quando a key repete ou usa índice, e como escolher a chave certa.
Each child in a list should have a unique "key" prop. é o aviso de que os
elementos irmãos produzidos por uma lista não receberam identidades estáveis.
Na primeira renderização tudo pode parecer correto; o problema aparece quando o
React precisa reconciliar itens que mudaram de posição, entraram ou saíram.
Pense no guarda-volumes de um evento. A key é o número permanente do ticket;
o índice é apenas a posição atual na fila. Se alguém sai do meio, as posições
mudam, mas cada mala continua pertencendo ao mesmo ticket. No React, a chave
permite associar o elemento novo ao nó e ao estado anteriores. Uma key instável
faz o React reaproveitar a linha errada ou desmontá-la e criar outra.
Todos os exemplos aqui são a lista de chamada do 9º ano A de uma escola: matrícula, nome, presença e nota. É o mesmo cenário de renderizar listas no React com map, só que aqui o foco é o que dá errado.
A mensagem completa e o componente que ela aponta
O aviso nasce de um .map() que devolve elementos sem key:
const alunos = [
{ matricula: '2026-014', nome: 'Ana Beatriz', turma: '9A' },
{ matricula: '2026-027', nome: 'Caio Ferraz', turma: '9A' },
{ matricula: '2026-031', nome: 'Diana Prado', turma: '9A' },
];
function ListaDeChamada() {
return (
<ul>
{alunos.map((aluno) => (
<li>{aluno.nome}</li>
))}
</ul>
);
}Check the render method of ListaDeChamada. See https://react.dev/link/warning-keys for more information.
Repare na segunda linha: Check the render method of ListaDeChamada. Esse nome
é o presente que a mensagem te dá. Num projeto com trinta componentes, ele diz
exatamente qual arquivo abrir — e não é o componente da linha, é o componente que
chamou o .map(). Quem produziu o array é quem tinha que carimbar cada item.
E a lista renderizou. O HTML saiu completo, com os três <li> na ordem certa:
console.log(document.getElementById('root').innerHTML);É por isso que o aviso é tão fácil de ignorar. A tela está certa. O que está errado é o que o React sabe sobre ela.
Onde o React procura a key — não é onde você imagina
A key vai no elemento que a função do map devolve. Não adianta pôr num
filho dele. Este código continua avisando:
function ListaErrada() {
return (
<ul>
{alunos.map((aluno) => (
<li>
<span key={aluno.matricula}>{aluno.nome}</span>
</li>
))}
</ul>
);
}Check the render method of ListaErrada. See https://react.dev/link/warning-keys for more information.
O <span> tem key, o <li> não. E é o <li> que é irmão dos outros dentro do
<ul> — a key precisa estar no topo do que sai do map, seja uma tag HTML,
seja um componente seu.
Quando o item vira um componente, aparece a segunda pegadinha: key não é
uma prop. O React tira essa chave do objeto antes de montar as props:
function LinhaDoAluno(props) {
console.log('props que chegaram:', Object.keys(props), '| props.key =', props.key);
return <li>{props.nome}</li>;
}
function ListaCerta() {
return (
<ul>
{alunos.map((aluno) => (
<LinhaDoAluno key={aluno.matricula} nome={aluno.nome} />
))}
</ul>
);
}Três alunos, três linhas de log, e em nenhuma delas props.key tem valor. O
aviso sobre key is not a prop aparece uma vez só porque o React não repete a
mesma mensagem para o mesmo componente.
Se o componente precisa da matrícula para alguma coisa, passe de novo com outro
nome: <LinhaDoAluno key={aluno.matricula} matricula={aluno.matricula} />.
Parece redundante, e não é: um valor é para o React, o outro é para você.
A terceira armadilha é o espalhamento. Se o objeto do aluno já tem um campo
chamado key e você espalha ele no JSX, o React reclama antes mesmo de
renderizar:
// a API já devolve o campo chamado "key"
const daApi = [
{ key: '2026-014', nome: 'Ana Beatriz' },
{ key: '2026-027', nome: 'Caio Ferraz' },
];
function ListaEspalhada() {
return (
<ul>
{daApi.map((aluno) => (
<li {...aluno}>{aluno.nome}</li>
))}
</ul>
);
}E olhe o HTML que saiu: o campo nome virou atributo HTML no <li>, porque
espalhar objeto de dado em tag nativa despeja tudo no DOM.
Key repetida: o aviso muda de texto e de gravidade
Existe um segundo aviso, e ele é bem mais sério que o primeiro. Aparece quando duas linhas irmãs recebem a mesma key — o caso clássico é a planilha da secretaria que importou o mesmo aluno duas vezes:
function ListaDeChamada({ alunos }) {
return (
<ul>
{alunos.map((aluno) => (
<li key={aluno.matricula}>{aluno.nome}</li>
))}
</ul>
);
}
const turmaImportada = [
{ matricula: '2026-014', nome: 'Ana Beatriz' },
{ matricula: '2026-027', nome: 'Caio Ferraz' },
{ matricula: '2026-027', nome: 'Caio Ferraz (2)' },
{ matricula: '2026-031', nome: 'Diana Prado' },
];
// a professora clica em "ordenar por nota" e a lista muda de ordem
const porNota = [turmaImportada[1], turmaImportada[0], turmaImportada[2], turmaImportada[3]];
const linhas = () => [...document.querySelectorAll('li')].map((li) => li.textContent);
const root = createRoot(document.getElementById('root'));
await act(() => root.render(<ListaDeChamada alunos={turmaImportada} />));
console.log('array:', turmaImportada.length, 'alunos | tela:', linhas().length, 'linhas ->', linhas().join(' | '));
await act(() => root.render(<ListaDeChamada alunos={porNota} />));
console.log('array:', porNota.length, 'alunos | tela:', linhas().length, 'linhas ->', linhas().join(' | '));Leia com calma a última linha. O array tem quatro alunos e a tela tem cinco linhas. O Caio apareceu duas vezes. Não é um aviso teórico sobre boas práticas: é a tela mostrando um aluno que não existe no dado. E repare que na primeira renderização a tela estava certa: o aviso já tinha saído — ele se repete a cada render, por isso aparece duas vezes aí em cima —, mas a linha fantasma só nasceu no update.
O bug de verdade: o input que troca de linha
Agora o caso que faz o time perder uma tarde. Cada linha da chamada tem um campo
de nota. O campo guarda o que foi digitado — esse valor mora no DOM, não no seu
array. É a key que diz ao React qual campo pertence a qual aluno.
function LinhaDeNota({ aluno }) {
return (
<li>
<label>{aluno.nome}</label>
<input type="text" defaultValue="" />
</li>
);
}
function Chamada({ alunos, usarIndice }) {
return (
<ul>
{alunos.map((aluno, indice) =>
usarIndice ? (
<LinhaDeNota key={indice} aluno={aluno} />
) : (
<LinhaDeNota key={aluno.matricula} aluno={aluno} />
),
)}
</ul>
);
}O roteiro do teste é o de uma professora real: ela digita as três notas e só depois descobre que a Ana trancou a matrícula e sai da lista.
const turma = [
{ matricula: '2026-014', nome: 'Ana Beatriz' },
{ matricula: '2026-027', nome: 'Caio Ferraz' },
{ matricula: '2026-031', nome: 'Diana Prado' },
];
const foto = () =>
[...document.querySelectorAll('li')]
.map((li) => `${li.querySelector('label').textContent}=${li.querySelector('input').value}`)
.join(' ');
async function rodar(usarIndice) {
const root = createRoot(document.getElementById('root'));
await act(() => root.render(<Chamada alunos={turma} usarIndice={usarIndice} />));
['8.5', '7.0', '9.5'].forEach((nota, i) => {
document.querySelectorAll('input')[i].value = nota;
});
console.log('digitado:', foto());
await act(() => root.render(<Chamada alunos={turma.slice(1)} usarIndice={usarIndice} />));
console.log('sem Ana :', foto());
await act(() => root.unmount());
}Primeiro com o índice como key:
await rodar(true);Agora exatamente o mesmo roteiro, mudando só a key:
await rodar(false);Com key={indice}, o Caio ficou com o 8,5 da Ana. Ninguém digitou isso.
O React viu que a posição 0 continuava existindo, achou que era o mesmo item,
manteve o mesmo <input> no lugar e só trocou o texto do <label>. A nota ficou
onde estava enquanto o nome andou.
Com key={aluno.matricula}, o React reconhece que o item 2026-014 sumiu,
descarta aquela linha inteira e as outras duas seguem com a nota certa.
A moral: a key não é um identificador do dado, é a identidade que o React usa
para casar o que existe na tela com o que veio no novo render. Índice não é
identidade de aluno — é identidade de posição.
Ordenar a lista faz a mesma coisa
Não precisa remover ninguém. Basta reordenar. Aqui cada linha tem um checkbox de presença, e só a Ana está presente:
// a mesma Chamada, com um checkbox de presença no lugar do campo de nota
function Chamada({ alunos, usarIndice }) {
return (
<ul>
{alunos.map((aluno, indice) => (
<li key={usarIndice ? indice : aluno.matricula}>
<label>{aluno.nome}</label>
<input type="checkbox" />
</li>
))}
</ul>
);
}
const presencas = () =>
[...document.querySelectorAll('li')]
.map((li) => {
const nome = li.querySelector('label').textContent;
return `${nome}=${li.querySelector('input').checked ? 'presente' : 'ausente'}`;
})
.join(' ');
const porNomeDesc = [...turma].sort((a, b) => b.nome.localeCompare(a.nome));
async function rodar(usarIndice) {
const root = createRoot(document.getElementById('root'));
await act(() => root.render(<Chamada alunos={turma} usarIndice={usarIndice} />));
document.querySelectorAll('input')[0].checked = true; // só Ana está presente
console.log('antes de ordenar:', presencas());
await act(() => root.render(<Chamada alunos={porNomeDesc} usarIndice={usarIndice} />));
console.log('ordem Z→A :', presencas());
await act(() => root.unmount());
}Com o índice:
await rodar(true);Com a matrícula:
await rodar(false);A professora marcou a Ana, clicou em ordenar, e quem ficou presente foi a Diana. Vale para checkbox do DOM e vale igual para estado de componente feito com useState: o estado mora na posição da árvore que a key determina. Se a key muda de dono, o estado muda de dono junto. Se quiser entender o mecanismo de reconciliação por trás disso, ele está detalhado em por que o componente roda de novo.
O aviso some no build de produção; o bug não
Este é o motivo de não deixar para depois. O aviso de key é uma verificação de
desenvolvimento: o React de produção nem executa esse trecho. Salvei num arquivo
chamada.jsx o mesmo Chamada e LinhaDeNota do campo de nota — o mesmo
roteiro de digitar as três notas e remover a Ana —, só que agora sem key
nenhuma, e rodei esse arquivo nos dois builds:
# o build de desenvolvimento — o mesmo transform que o Vite usa em npm run dev
npx esbuild chamada.jsx --format=esm --jsx=automatic --jsx-dev --outfile=dev.mjs
node dev.mjsCheck the render method of Chamada. See https://react.dev/link/warning-keys for more information.
digitado: Ana Beatriz=8.5 Caio Ferraz=7.0 Diana Prado=9.5
sem Ana : Caio Ferraz=8.5 Diana Prado=7.0
E o mesmo arquivo com o React de produção — é o NODE_ENV que faz o pacote
carregar o outro build, sem nenhuma das verificações de desenvolvimento:
npx esbuild chamada.jsx --format=esm --jsx=automatic --outfile=prod.mjs
NODE_ENV=production node prod.mjsMesma nota errada nos dois. A única diferença é que em produção ninguém te avisa. O console limpo depois do deploy não significa que o problema sumiu: significa que ele virou um chamado de suporte com o título “a nota do aluno mudou sozinha”.
Quando o índice serve mesmo
Índice não é proibido. Ele é uma identidade correta quando a posição é a identidade — quando a lista nunca muda de ordem nem perde item do meio.
| situação da lista | índice serve? | por quê |
|---|---|---|
| estática, renderizada uma vez (menu, colunas fixas) | sim | a posição nunca muda, logo nunca mente |
| só cresce no fim (log, mensagens de chat antigas em cima) | sim | nenhum índice existente é reatribuído |
| tem remover, filtrar, buscar ou ordenar | não | a posição muda de dono e o estado vai junto |
| cada linha tem input, checkbox, foco ou animação | não | esse estado mora na posição — e a posição escorrega |
o item já tem id, uuid, slug ou matrícula |
não | você tem a identidade de verdade na mão, use ela |
Na dúvida, use a identidade do dado. Não existe caso em que o índice é melhor que
um id estável, então a pergunta prática é sempre “eu tenho um id?” — e não “o
índice dá conta aqui?”.
Sem id no dado: como fabricar uma key estável
Às vezes o dado chega sem identificador nenhum. A planilha da secretaria tem só nome e turma. A saída tentadora é compor a key a partir dos campos — e ela funciona até dois alunos terem o mesmo nome:
const planilha = [
{ nome: 'Ana Beatriz', turma: '9A' },
{ nome: 'Caio Ferraz', turma: '9A' },
{ nome: 'Ana Beatriz', turma: '9A' },
];
function ListaComposta() {
return (
<ul>
{planilha.map((aluno) => (
<li key={`${aluno.turma}-${aluno.nome}`}>{aluno.nome}</li>
))}
</ul>
);
}Homônimo em escola não é caso raro, é rotina. A correção é dar um id ao dado no momento em que ele entra no aplicativo — na normalização da resposta da API, uma vez só, e não a cada render:
// roda uma vez, quando o dado chega
const alunos = planilha.map((aluno) => ({ id: crypto.randomUUID(), ...aluno }));
function ListaComId() {
return (
<ul>
{alunos.map((aluno) => (
<li key={aluno.id}>{aluno.nome}</li>
))}
</ul>
);
}
console.log('ids gerados:', alunos.map((a) => a.id.slice(0, 8)).join(', '));Sem aviso nenhum, e as duas Anas passam a ser duas pessoas diferentes para o
React. O detalhe que decide tudo é onde a linha do crypto.randomUUID()
mora: fora do componente, junto do dado. Dentro do render, ela vira o problema da
última seção.
Fragment dentro de lista também precisa de key
Quando cada item devolve dois elementos irmãos — um <dt> e um <dd>, por
exemplo — a vontade é usar o fragmento curto <>. Só que ele não aceita
atributo nenhum, key inclusive:
const alunos = [
{ matricula: '2026-014', nome: 'Ana Beatriz', falta: 2 },
{ matricula: '2026-027', nome: 'Caio Ferraz', falta: 5 },
];
function BoletimCurto() {
return (
<dl>
{alunos.map((aluno) => (
<>
<dt>{aluno.nome}</dt>
<dd>{aluno.falta} faltas</dd>
</>
))}
</dl>
);
}Check the render method of BoletimCurto. See https://react.dev/link/warning-keys for more information.
A forma longa resolve, e é para isso que ela existe:
import { Fragment } from 'react';
function BoletimComKey() {
return (
<dl>
{alunos.map((aluno) => (
<Fragment key={aluno.matricula}>
<dt>{aluno.nome}</dt>
<dd>{aluno.falta} faltas</dd>
</Fragment>
))}
</dl>
);
}Nenhum aviso, e o <Fragment> não deixa marca no HTML: os <dt> e <dd> são
filhos diretos do <dl>, como deviam ser.
Por que Math.random() como key é pior que o índice
Essa é a “solução” que aparece em resposta de fórum: se a key precisa ser única, gere uma aleatória e o aviso some. Some mesmo. E aí você troca um bug intermitente por um bug permanente.
function Chamada({ filtro, comRandom }) {
const visiveis = turma.filter((a) => a.nome.includes(filtro));
return (
<ul>
{visiveis.map((aluno) => (
<li key={comRandom ? Math.random() : aluno.matricula}>
<label>{aluno.nome}</label>
<input type="text" defaultValue="" />
</li>
))}
</ul>
);
}O teste: digitar a nota na primeira linha e disparar um render qualquer — no caso,
a professora digitando no campo de busca sem mudar o resultado. Guardei o nó
<li> antes e comparei com o de depois usando ===:
const primeiroLi = () => document.querySelector('li');
async function rodar(comRandom) {
const root = createRoot(document.getElementById('root'));
await act(() => root.render(<Chamada filtro="" comRandom={comRandom} />));
const noAntes = primeiroLi();
document.querySelector('input').value = '8.5';
console.log('nota digitada na 1a linha:', document.querySelector('input').value);
// novo render, exatamente a mesma lista
await act(() => root.render(<Chamada filtro="" comRandom={comRandom} />));
console.log('mesmo nó <li> depois do re-render?', noAntes === primeiroLi());
console.log('nota que sobrou na 1a linha :', JSON.stringify(document.querySelector('input').value));
await act(() => root.unmount());
}Com a key aleatória:
await rodar(true);Com a matrícula, o mesmo roteiro:
await rodar(false);O false da primeira saída é a prova: com Math.random() o <li> é um
elemento novo em folha a cada render, ou seja, o React destrói a linha inteira e
monta outra. A nota digitada evaporou, e o mesmo aconteceria com o foco do teclado, com
o texto selecionado e com qualquer estado interno da linha.
O índice pelo menos erra de vez em quando — só quando a lista muda. O
Math.random() erra sempre, inclusive quando nada mudou. E como o valor é
diferente a cada chamada, ele também apaga qualquer chance de o React reaproveitar
trabalho. Se quiser ver como essa função se comporta,
Math.random na prática mostra o
que ela devolve. O que ela não devolve é identidade.
O que fazer agora
Abra o console, procure o nome que vem depois de Check the render method of e
vá naquele componente. Se o item já tem id ou matrícula, o conserto é de um
caractere. Se não tem, gere o id na normalização do dado, não no render.
Confirme a correção com uma lista que tenha um <input> por linha: digite na
segunda linha, remova a primeira e depois ordene os itens. Com key={id}, o
texto e o foco devem continuar ligados ao mesmo registro. Repita com
key={index} para observar a identidade acompanhar a posição. Esse antes e
depois prova o problema melhor do que apenas fazer o aviso sumir.
Depois disso, o próximo lugar onde a key costuma morder é o formulário: campo
controlado dentro de lista junta os dois assuntos deste artigo. A lição de
formulário controlado no React mostra
como o value e o onChange se ligam ao estado, e
map, filter e reduce recapitula o método
que gera a lista em primeiro lugar. O caminho completo de estudo, na ordem, está
no guia de React e na
trilha de React.
Perguntas frequentes
O aviso de key quebra a minha aplicação?
Posso usar o índice do map como key?
A key precisa ser única no mundo todo?
Por que props.key vem undefined dentro do componente?
Dá para desligar esse aviso?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0, React 19.2.8, react-dom 19.2.8, jsdom 30.0.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- React — Rendering Lists — react.dev
- React — Preserving and Resetting State — react.dev
- React — Fragment (keyed fragments) — react.dev


