0. 바쁜 현대인을 위한 3줄 요약
key={index}를 쓰면 배열의 순서가 바뀔 때 데이터 꼬임(Data Corruption)과 화면 깨짐이 발생합니다. (특히 Input 폼에서 치명적)- React는
key를 주민등록번호처럼 사용해서 어떤 컴포넌트를 재사용할지 결정합니다. 인덱스는 바뀔 수 있는 값이므로 주민등록번호가 될 수 없습니다. - DB에서 온 데이터라면
id를 쓰고, 클라이언트에서 만든 데이터라면crypto.randomUUID()로 고유 ID를 만들어 쓰세요.
1. "경고창 좀 그만 떠!"
React를 처음 배울 때, 콘솔 창을 빨갛게 도배하는 경고 문구가 있었습니다.
Warning: Each child in a list should have a unique "key" prop.
"아, 알았어. 주면 될 거 아냐."
저는 귀찮아서 그냥 map 함수의 두 번째 인자인 index를 넣었습니다.
/* 나쁜 예 */
{items.map((item, index) => (
<div key={index}>{item.name}</div>
))}
경고가 사라졌습니다. 화면도 잘 나옵니다. "뭐야, 별거 아니네." 라고 생각하며 저는 지옥문을 열었습니다.
2. 유령 데이터 사건
제가 만든 기능은 "사용자 추가 폼"이었습니다.
[이름 입력] [삭제 버튼] 이렇게 생긴 줄이 여러 개 있는 구조였죠.
테스트를 해봤습니다.
- 첫 줄에 "철수" 입력
- 둘째 줄에 "영희" 입력
- 셋째 줄에 "바둑이" 입력
그리고 "철수" 라인을 삭제했습니다. 제 예상: "영희", "바둑이"가 남는다. 실제 결과: "철수", "영희"가 남고 "바둑이"가 사라졌다??
더 무서운 건, 입력한 데이터("영희")는 그대로인데, 삭제 버튼이 엉뚱한 줄에 붙어있는 것처럼 동작했습니다. 마치 영혼이 뒤바뀐 것처럼요.
graph TD
subgraph "내 기대 (Expectation)"
A[철수 - 삭제됨]
B[영희 - 유지] --> B_Keep[영희]
C[바둑이 - 유지] --> C_Keep[바둑이]
end
subgraph "현실 (Reality with Key=index)"
A_Real[Index 0: 철수] --> A_New[Index 0: 영희?]
B_Real[Index 1: 영희] --> B_New[Index 1: 바둑이?]
C_Real[Index 2: 바둑이] --> C_Gone[삭제됨]
end
style A_New fill:#f9f,stroke:#333
style B_New fill:#f9f,stroke:#333
"이게 말이 돼? items 배열에는 분명 철수가 지워지고 영희, 바둑이만 남았는데 왜 화면에는 철수가 남아있지?"
콘솔에 console.log(items)를 찍어봐도 데이터는 정상이었습니다.
데이터는 정상인데 화면만 이상한 상황.
귀신에 홀린 기분이었습니다.
2.5. 또 다른 피해 사례 - 쇼핑몰 장바구니 대참사
제 친구가 겪은 실제 사례입니다. 쇼핑몰 장바구니 페이지를 만들고 있었습니다.
[상품 A (수량: 1)] [+] [-]
[상품 B (수량: 1)] [+] [-]
[상품 C (수량: 1)] [+] [-]
여기서 사용자가 상품 A의 수량을 5로 늘렸습니다. (local state 변경)
그리고 마음이 바뀌어서 상품 A를 삭제했습니다. (리스트에서 제거)
key={index}를 썼다면 어떻게 될까요?
React는 첫 번째 DOM(상품 A가 있던 자리)을 재사용해서 상품 B의 데이터를 보여줍니다.
하지만 수량 입력창(Input)은 DOM 상태이므로 그대로 5가 남아있습니다.
결과: 사용자는 상품 B가 수량 5개 담긴 것으로 보게 됩니다.
사용자가 눈치채지 못하고 결제하면? 대형 클레임으로 이어집니다.
이처럼 key={index}는 단순한 UI 깨짐이 아니라 금전적 손실을 유발할 수 있는 치명적인 버그입니다.
2.6. 버그의 해부학 - 왜 데이터는 맞는데 화면은 틀릴까?
이것이 바로 "제어 컴포넌트(Controlled)"와 "비제어 컴포넌트(Uncontrolled)"의 차이, 그리고 DOM 상태의 문제입니다.
- React State (
value={state}): React가 관리합니다. Props가 바뀌면 착하게 바뀝니다. - DOM State (사용자 입력, 포커스, 스크롤): 브라우저가 관리합니다.
React는 효율성을 위해 "기존 DOM을 재활용"합니다.
key가 같으면(index 0), React는 "어, 0번 DOM 있네? 재사용!" 이라고 판단합니다.
Props(name="영희")는 업데이트해 주지만, DOM이 품고 있는 '철수'라는 텍스트 상태(Local State)는 초기화하지 않고 그대로 둡니다.
그래서 "영희"라는 명찰을 단 "철수(의 영혼을 가진 DOM)"가 탄생하는 겁니다.
3. 원인 - React는 멍청(?)하다
이 문제를 이해하려면 React가 화면을 그리는 방식(Reconciliation)을 알아야 합니다. React는 변경 전과 변경 후의 트리를 비교해서, "최소한의 변경"만 실제 돔(DOM)에 반영하려고 노력합니다.
이때 key를 기준으로 비교합니다.
상황 재구성
[삭제 전]
key=0: 철수 (Input DOM 1)key=1: 영희 (Input DOM 2)key=2: 바둑이 (Input DOM 3)
여기서 첫 번째 항목(철수)을 배열에서 지웁니다.
그러면 배열은 ["영희", "바둑이"]가 되죠.
다시 렌더링 할 때 index를 key로 쓰면 이렇게 됩니다.
[삭제 후]
key=0: 영희 (Input DOM 1 재사용)key=1: 바둑이 (Input DOM 2 재사용)- (key=2는 없음 → Input DOM 3 삭제)
React 입장에서는 이렇게 보입니다.
"어?
key=0이랑key=1은 그대로 있네? 내용물(Props)만 '철수'에서 '영희'로 바꿔줘야지. 그리고key=2는 없어졌네? 쟤는 지워."
문제는 Input DOM이 가진 상태(사용자가 타이핑한 텍스트)는 폼 요소의 내부 상태(Local State)라서, React가 Props만 갈아 끼워도 그대로 남아있을 수 있다는 겁니다. (Uncontrolled Component인 경우 더 심각함)
결국 껍데기(DOM)는 그대로 두고 알맹이(데이터)만 밀려 올라가는 대참사가 벌어집니다.
4. 해결책 - 주민등록번호를 발급하라
해결책은 간단합니다. 변하지 않는 고유 ID를 key로 주는 겁니다.
사람들이 줄 서 있는데(index), 맨 앞 사람이 나간다고 해서 두 번째 사람의 주민등록번호가 바뀌지는 않잖아요?
/* 좋은 예 */
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
이렇게 하면 React는 이렇게 판단합니다.
"아,
key=철수ID가 없어졌네? 그 DOM을 지워야지.key=영희ID랑key=바둑이ID는 그대로 놔두고."
이제 폼 삭제 기능이 정상적으로 동작합니다.
5. "저는 DB에 ID가 없는데요?"
백엔드에서 오는 데이터에는 ID가 있겠지만, 클라이언트에서 임시로 추가하는 항목엔 ID가 없을 수 있습니다.
그럴 땐 라이브러리를 써서 만드세요. uuid나 nanoid 같은 거요.
import { v4 as uuidv4 } from 'uuid';
const addItem = () => {
setItems([...items, { id: uuidv4(), text: '' }]);
};
최신 브라우저라면 라이브러리 없이 네이티브 API를 쓸 수도 있습니다.
// 훨씬 빠르고 가볍습니다! (IE는 안 됨 주의)
const id = self.crypto.randomUUID();
절대 하면 안 되는 것들 (Anti-Patterns)
1. Math.random()을 key로 주기
// 렌더링 될 때마다 새로운 ID?? -> 100% 리마운트 발생 -> 포커스 잃음
{items.map(item => <input key={Math.random()} />)}
이렇게 하면 React는 매번 "어? 새로운 컴포넌트네?" 하고 기존 Input을 파괴하고 새로 만듭니다. 결과적으로 사용자가 타이핑할 때마다 포커스가 풀려서, 한 글자 치고 클릭하고, 한 글자 치고 클릭해야 하는 끔찍한 UX가 탄생합니다.
2. index 더하기 신공
key={index + 1} 같은 건 눈 가리고 아웅입니다. 어차피 순서 밀리면 똑같이 망가집니다.
5.5. 함정 카드: Date.now()
"그럼 ID 대신 현재 시간을 쓰면 안 되나요?"
// 절대 안 됨!
{items.map(item => <div key={Date.now()} />)}
렌더링은 밀리초(ms) 단위보다 훨씬 빠르게 일어날 수 있습니다.
반복문 안에서 Date.now()를 부르면 똑같은 시간이 찍힐 수 있고, 키 중복 에러가 터집니다.
게다가 렌더링 할 때마다 시간이 바뀌니, Math.random()과 똑같이 모든 컴포넌트가 리마운트되는 성능 참사가 벌어집니다.
key는 데이터가 생성될 때 딱 한 번 만들어져서 데이터와 함께 다녀야 합니다. 렌더링 중에 만들지 마세요.
7. 왜 React는 Key를 강요할까? (O(n^3)의 비밀) 깊이 들여다보기
사실 React가 괴팍해서 그런 게 아닙니다. 컴퓨터 공학적인 이유가 있습니다. 일반적인 "트리 비교 알고리즘(Tree Edit Distance)"의 시간 복잡도는 O(n^3)입니다. 만약 아이템이 1,000개라면, 1,000^3 = 10억 번의 연산이 필요합니다. 브라우저가 멈추겠죠.
그래서 React 팀은 "휴리스틱(Heuristic)" 알고리즘을 도입해서 이를 O(n)으로 줄였습니다. 단, 한 가지 전제 조건이 필요했습니다.
"개발자가
key를 통해 어떤 자식이 변경되지 않았는지 알려준다면, 우리는 O(n)만에 비교를 끝낼 수 있다."
즉, key는 React와의 계약입니다.
여러분이 key={index}를 쓰는 것은, React와의 계약서에 서명을 위조하는 것과 같습니다.
계약을 어겼으니, 버그라는 위약금을 무는 것이죠.
7. 리액트의 속마음 (Reconciliation Algorithm) 제대로 이해하기
"도대체 key가 뭐길래 이렇게 유난을 떠는 걸까?"
React의 렌더링 엔진인 Fiber의 작동 방식을 조금만 더 깊게 파봅시다.
React는 렌더링 할 때 두 개의 트리를 가지고 있습니다.
- current tree: 지금 화면에 보이는 트리
- workInProgress tree: 새로 만들고 있는 트리
리스트를 렌더링 할 때, React는 current의 리스트와 workInProgress의 리스트를 비교(Diff)합니다.
이때 O(N) 속도로 비교하기 위해 key를 맵(Map)의 Key로 사용합니다.
- Key가 같으면: "아, 같은 컴포넌트네/DOM이네." ->
props만 업데이트 (Update) - Key가 다르면: "어, 다른 놈이네." -> 기존 거 파괴(Unmount)하고 새로 생성 (Mount)
만약 key를 index로 쓰면, 중간에 아이템이 삽입되었을 때 모든 뒷부분 아이템들의 key(index)가 밀리면서 다 바뀝니다.
React 입장에서는 "어? 뒤에 있는 100개가 싹 다 다른 놈으로 변했네? 전부 다 부수고 새로 만들어!" 라고 판단하는 겁니다.
이게 바로 성능 저하의 원인입니다.
반대로 uuid를 쓰면, 순서가 바뀌어도 key는 그대로이므로, React는 DOM 노드를 파괴하지 않고 위치만 이동(Move)시킵니다. 이것이 리스트 가상화와 성능 최적화의 핵심입니다.
7.5. 실제 리팩토링: Before & After
실제 프로덕션 코드에서 발견한 끔찍한 혼종 코드를 리팩토링 한 사례입니다.
[Before: 혼돈의 카오스]
// BE에서 ID를 안 줘서 대충 짠 코드
const UserList = ({ users }) => {
return (
<div>
{users.map((user, idx) => (
// key에 index + Math.random? 최악의 조합!
<div key={`${idx}-${Math.random()}`}>
<input defaultValue={user.name} />
<button onClick={() => deleteUser(idx)}>삭제</button>
</div>
))}
</div>
);
};
이 코드는 입력할 때마다 포커스가 풀리고, 삭제하면 엉뚱한 게 지워지는 종합 병원입니다.
[After: 평화와 안정]
// 데이터 수신 시점에 ID 부착!
const UserList = ({ users }) => {
// useMemo로 ID 생성을 고정 (데이터가 같으면 ID도 유지)
const usersWithId = useMemo(() => {
return users.map(user => ({
...user,
// 기존 ID가 있으면 쓰고, 없으면 생성 (crypto.randomUUID)
internalId: user.id || crypto.randomUUID()
}));
}, [users]);
return (
<div>
{usersWithId.map((user) => (
// 고유 ID 사용!
<div key={user.internalId}>
<input defaultValue={user.name} />
{/* 삭제할 때도 index 대신 ID 사용 */}
<button onClick={() => deleteUser(user.internalId)}>삭제</button>
</div>
))}
</div>
);
};
6. 성능에도 치명적이다 (Performance Killer)
"에이, 우리 앱은 간단해서 괜찮아." 라고 생각하시나요?
key={index}는 버그뿐만 아니라 성능도 잡아먹습니다.
만약 리스트의 맨 앞에 아이템을 하나 추가한다고 가정해 봅시다.
-
key={id}사용 시:- React: "어?
NewID가 새로 생겼네? 맨 앞에 DOM 하나만 추가하면 끝!" (1번 연산)
- React: "어?
-
key={index}사용 시:- React: "어? 0번 내용이 바뀌었네? (Update)"
- "어? 1번 내용도 바뀌었네? (Update)"
- ...
- "어? 100번 내용도 바뀌었네? (Update)"
- "그리고 마지막에 하나 추가됐네? (Append)"
- 결과: 리스트 전체를 다시 그립니다.
아이템이 100개면 100번, 1000개면 1000번의 불필요한 DOM 조작이 일어납니다. 배터리가 살살 녹는 소리가 들리지 않나요?
7.8. Key에 대한 오해와 진실 (Q&A)
key에 대해 자주 묻는 질문들을 정리해 보았습니다.
Q1. Key는 전역적으로 고유해야 하나요? (Globally Unique?)
A. 아니요. 형제(Siblings) 사이에서만 고유하면 됩니다.
다른 컴포넌트나 다른 리스트에 있는 key와 겹치는 건 전혀 상관없습니다.
// 이건 괜찮습니다.
<ul>
<li key="1">A</li>
</ul>
<ul>
<li key="1">B</li>
</ul>
Q2. 두 개의 아이템이 똑같은 ID를 가지면 어떻게 되나요?
A. React가 경고를 띄우고, 렌더링이 꼬입니다. 예를 들어 체크박스를 눌렀는데 엉뚱한 놈이 체크될 수 있습니다. 데이터 상에서 중복 ID가 있다면, 렌더링 하기 전에 index를 섞어서라도(물론 정적일 때만) 고유하게 만들어야 합니다.
Q3. Fragment(<>)에도 Key를 줄 수 있나요?
A. 네, 단 축약형 <>...</> 은 안 되고, 명시적으로 <React.Fragment key={id}>...</React.Fragment>라고 써야 합니다.
주로 map 안에서 여러 엘리먼트를 묶어서 반환할 때 사용합니다.
items.map(item => (
<React.Fragment key={item.id}>
<dt>{item.term}</dt>
<dd>{item.description}</dd>
</React.Fragment>
))
Q4. Math.random()을 쓰면 안 된다면서 crypto.randomUUID()는 왜 되나요?
A. 렌더링 도중에(in render) 호출하느냐, 데이터 생성 시점에(in data creation) 호출하느냐의 차이입니다.
map 함수 안에서 key={uuid()}를 하면 렌더링마다 바뀝니다(최악).
하지만 데이터를 useState에 넣을 때 uuid를 생성해서 객체에 박아두고, 렌더링 할 때는 그 값을 key={item.uuid}로 읽기만 하는 건 정석입니다.
Q5. 인덱스를 키로 써도 되는 유일한 예외는 없나요? A. 있습니다. 다음 3가지 조건을 모두 만족하면 써도 됩니다.
- 리스트가 계산되지 않음 (Static)
- 리스트가 변경되지 않음 (No Filter, No Reorder)
- 아이템에 ID가 없음 예를 들어, "약관 동의 페이지의 약관 항목 1, 2, 3" 처럼 절대 불변하는 정적 텍스트 리스트라면 괜찮습니다. 하지만 저는 습관을 위해 비추천합니다.
7.9. 한 눈에 보는 요약표
상황별 올바른 Key 사용 전략을 정리했습니다.
| 상황 | 추천 Key | 설명 |
|---|---|---|
| DB 데이터 리스트 | item.id | 무조건 DB PK(Primary Key)를 사용하세요. 가장 안전합니다. |
| 클라이언트 생성 데이터 | crypto.randomUUID() | 데이터 생성 시점에 ID를 만들어 객체에 포함시키세요. |
| 정적인 메뉴 (Footer) | index | 리스트가 절대 변하지 않는다면 인덱스도 괜찮습니다. |
| 폼 입력 필드 (Input) | id (필수) | 인덱스 쓰면 데이터 꼬임(Ghost Data) 현상 100% 발생합니다. |
| 드래그 앤 드롭 | id (필수) | 순서가 바뀌는 리스트에서 인덱스 쓰면 애니메이션 다 깨집니다. |
| 페이지네이션 | id | 페이지 넘길 때 컴포넌트 재사용을 막으려면 ID가 필요합니다. |
절대 쓰면 안 되는 것들 (Blacklist):
Math.random(): 렌더링 할 때마다 리스트 전체가 깜빡거림 (Remount).Date.now(): 위와 동일. 성능 파괴범.index(변하는 리스트에서): 버그 제조기.
8. 마무리 - 경고는 당신을 살리기 위해 존재한다
React가 뻘건 글씨로 경고를 띄우는 건 심심해서가 아닙니다.
key는 단순한 성능 최적화 도구가 아닙니다. 앱의 논리적 무결성을 지키는 핵심 장치입니다.
지금 당장 코드베이스를 열어서 key={index}를 검색해 보세요.
그리고 그 리스트가 "순서가 바뀌거나, 추가/삭제될 수 있는 리스트"라면, 당신은 시한폭탄을 안고 있는 겁니다.
당장의 귀찮음 때문에 미래의 3시간 디버깅 지옥을 예약하지 마세요. (제가 그랬거든요.)
🚀 코드 리뷰 체크리스트
-
map돌리는 리스트에key가 있는가? - 그
key가index인가? (그렇다면 리스트가 변하는지 확인) -
key가Math.random()이나uuid()등 렌더링마다 변하는 값인가? (즉시 수정 필요) - 백엔드 데이터에
id가 없다면, 데이터 수신 시점에 ID를 생성해서 붙여주었는가?
9. 자동화 - 인간은 실수를 한다 (ESLint)
우리는 잊어버리겠지만, 기계는 잊지 않습니다.
eslint-plugin-react를 설치하고 아래 규칙을 켜세요.
"rules": {
"react/no-array-index-key": "warn"
}
이 룰은 index를 key로 쓰면 가차 없이 빨간 줄을 그어줍니다.
단, 리스트가 정적(Static)이고 절대 변하지 않는다면 예외적으로 허용해 줍니다.
하지만 경험상 "절대 변하지 않는 리스트"는 세상에 없습니다. 기획자는 언제나 마음을 바꾸니까요.
Focus Jumping Everywhere? The Curse of React key='index'
1. "Stop With the Warnings Already!"
When I first learned React, there was one warning that constantly flooded my console in red text.
Warning: Each child in a list should have a unique "key" prop.
"Okay, okay. I'll give you a key."
Out of laziness, I just passed the second argument of the map function, the index.
/* Bad Example */
{items.map((item, index) => (
<div key={index}>{item.name}</div>
))}
The warning disappeared. The screen rendered fine. "See? No big deal." Thinking that, I opened the Gates of Hell.
2. The Ghost Data Incident
The feature I was building was a "Add User Form".
It had multiple rows like [Name Input] [Delete Button].
I tested it.
- Type "Alice" in row 1.
- Type "Bob" in row 2.
- Type "Charlie" in row 3.
Then I deleted the "Alice" row. My expectation: "Bob" and "Charlie" remain. Actual Result: "Alice" and "Bob" remain, and "Charlie" disappeared??
Scarier still, the typed data ("Bob") was correct, but the DOM element seemed to hold the state of the deleted item. It was like their souls had swapped.
graph TD
subgraph "Expectation"
A[Alice - Deleted]
B[Bob - Kept] --> B_Keep[Bob]
C[Charlie - Kept] --> C_Keep[Charlie]
end
subgraph "Reality (with Key=index)"
A_Real[Index 0: Alice] --> A_New[Index 0: Bob?]
B_Real[Index 1: Bob] --> B_New[Index 1: Charlie?]
C_Real[Index 2: Charlie] --> C_Gone[Deleted]
end
style A_New fill:#f9f,stroke:#333
style B_New fill:#f9f,stroke:#333
"How is this possible? I logged items array and 'Alice' is clearly gone. Why is 'Alice' still on the screen?"
The data was correct, but the UI was haunted.
I felt like I was losing my mind.
2.5. Anatomy of the Bug: Why Data Matches but UI Fails?
This stems from the difference between React State and DOM State.
- React State: Managed by React. Updates nicely when props change.
- DOM State (User Input, Focus, Scroll): Managed by the Browser.
React recycles existing DOM nodes for efficiency.
If the key matches (index 0), React says, "Oh, DOM #0 exists? Reuse it!"
It updates the props (name="Bob"), but leaves the internal DOM state (the text "Alice") untouched.
So you end up with a DOM node that wears a "Bob" nametag but holds "Alice's" soul.
3. The Cause: React is Stupid (Kind of)
To understand this, you need to know how React draws screen updates (Reconciliation). React compares the tree before and after changes and tries to apply "Minimal Changes" to the real DOM.
It uses key as the unique identifier for comparison.
Reconstructing the Scene
[Before Deletion]
key=0: Alice (Input DOM 1)key=1: Bob (Input DOM 2)key=2: Charlie (Input DOM 3)
Here, I remove the first item (Alice) from the array.
The array becomes ["Bob", "Charlie"].
If I use index as key, here is what happens on re-render:
[After Deletion]
key=0: Bob (Reuses Input DOM 1 - originally Alice's)key=1: Charlie (Reuses Input DOM 2 - originally Bob's)- (key=2 is gone → Delete Input DOM 3)
React sees it this way:
"Huh?
key=0andkey=1are still there? I'll just update their props from 'Alice' to 'Bob'. Andkey=2is gone? Delete that one."
The problem is that the Input DOM's internal state (user typed text) allows React to just swap the props while keeping the DOM instance alive. (Especially bad with Uncontrolled Components).
The result is a disaster where the shell (DOM) stays put, but the content (Data) shifts up.
4. The Solution: Issue IDs
The solution is simple. Give a Unique ID that never changes as the key.
Even if the person at the front of the line (index 0) leaves, the social security number of the second person doesn't change.
/* Good Example */
{items.map((item) => (
<div key={item.id}>{item.name}</div>
))}
Now React judges correctly:
"Ah,
key=AliceIDis gone? Delete that specific DOM. Leavekey=BobIDandkey=CharlieIDalone."
Now the form deletion works as expected.
5. "But I Don't Have IDs in My DB?"
Data from the backend usually has IDs, but temporary items added on the client might not.
In that case, use a library like uuid or nanoid.
import { v4 as uuidv4 } from 'uuid';
const addItem = () => {
setItems([...items, { id: uuidv4(), text: '' }]);
};
If you are targeting modern browsers, you can use the native API without any library:
// Faster and lighter!
const id = self.crypto.randomUUID();
Absolute Anti-Patterns
1. Using Math.random() as key
// New ID every render?? -> 100% Remount -> Focus Lost
{items.map(item => <input key={Math.random()} />)}
If you do this, React thinks it's a completely new component every time. It destroys the old DOM and creates a new one. Result: The input loses focus after every keystroke. The user has to click, type one letter, click again... A UX nightmare.
2. The index Addition Trick
Using key={index + 1} is just fooling yourself. If the order shifts, it breaks just the same.
5.5. The Trap Card: Date.now()
"Can I use the current timestamp instead of an ID?"
// Absolutely NOT!
{items.map(item => <div key={Date.now()} />)}
Rendering can happen faster than milliseconds.
Calling Date.now() in a loop creates duplicate keys.
Worse, since the time changes every render, it behaves exactly like Math.random()—forcing a full remount every time.
key must be generated when data is created, and stay with the data. Never generate it during render.
6. It Also Kills Performance
"My app is simple, so it's fine." Do you think so?
key={index} is not just bug-prone, it eats performance for breakfast.
Imagine adding an item to the beginning of a list.
-
Using
key={id}:- React: "Oh, a new
NewIDappeared? Just insert one DOM node at the top!" (1 Operation)
- React: "Oh, a new
-
Using
key={index}:- React: "Oh, index 0 content changed? (Update)"
- "Index 1 content changed too? (Update)"
- ...
- "Index 100 content changed too? (Update)"
- "And a new item at the end? (Append)"
- Result: Re-rendering the entire list.
If you have 1000 items, that's 1000 unnecessary DOM operations. Can you hear your battery draining?
7. Deep Dive: Inside React's Mind (Reconciliation)
"Why does React care so much about key?"
Let's look under the hood of React Fiber, the rendering engine.
React maintains two trees during rendering:
- current tree: The one currently on screen.
- workInProgress tree: The one being built.
When comparing lists, React uses key to match nodes between these two trees in O(N) time. It treats key as a Hash Map key.
- Match Found: "Same component." -> Update
propsonly. - No Match: "Different component." -> Destroy (Unmount) old one, Create (Mount) new one.
Using index means inserting an item at the top shifts ALL following indices.
React sees: "Oh, keys 0 to 99 all changed? Destroy them all and rebuild!"
That is why it kills performance.
Using uuid allows React to see: "Oh, key=abc just moved from index 0 to index 1. I'll just Move the DOM node without destroying it."
This is the secret to 60 FPS list animations.
7.5. Real World Refactoring: Before & After
Here is a real refactoring case from a production codebase.
[Before: Chaos]
// API didn't return IDs, so developer got creative... nicely.
const UserList = ({ users }) => {
return (
<div>
{users.map((user, idx) => (
// index + random? The worst combination!
<div key={`${idx}-${Math.random()}`}>
<input defaultValue={user.name} />
<button onClick={() => deleteUser(idx)}>Delete</button>
</div>
))}
</div>
);
};
This code causes focus loss on every render and deletes wrong items. It's a disaster.
[After: Stability]
// Attach IDs at the data layer!
const UserList = ({ users }) => {
// Use useMemo to generate IDs once and keep them stable
const usersWithId = useMemo(() => {
return users.map(user => ({
...user,
// Use existing ID or generate one
internalId: user.id || crypto.randomUUID()
}));
}, [users]);
return (
<div>
{usersWithId.map((user) => (
// Use the stable internalId
<div key={user.internalId}>
<input defaultValue={user.name} />
{/* Use ID for deletion logic too */}
<button onClick={() => deleteUser(user.internalId)}>Delete</button>
</div>
))}
</div>
);
};
7. Deep Dive: Why Does React Force Keys? (The O(n^3) Secret)
Actually, React isn't just being annoying. There is a computer science reason. The time complexity of a general "Tree Edit Distance" algorithm is O(n^3). If you have 1,000 items, 1,000^3 = 1 Billion Operations. The browser would freeze instantly.
So the React team adopted a "Heuristic" algorithm to bring this down to O(n). But it required one premise:
"If the developer tells us which child is stable via a
key, we can finish the comparison in O(n)."
In other words, key is a contract with React.
Using key={index} is like forging a signature on that contract.
You broke our deal, so you pay the penalty (bugs).
💡 Pro Tip: When to Intentionally Change Keys?
Sometimes, you want to destroy and recreate a component.
For example, if you want to reset a form or re-trigger a CSS animation, changing the key is the cleanest way to do it.
// Changing 'resetKey' will destroy the old generic form and create a fresh one
<UserForm key={resetKey} />
In this case, you are using the key mechanism to your advantage (forcing a remount), rather than letting it cause accidental bugs.
8. Conclusion: Warnings Exist to Save You
React doesn't splash red warnings just for fun.
key is not just a performance optimization tool. It is a core mechanism to ensure Logical Integrity of the App.
Open your codebase right now and search for key={index}.
If that list is "A list that can be reordered, added to, or deleted from", you are sitting on a ticking time bomb.
Don't book a future 3-hour debugging session just to save 3 seconds of laziness now. (Like I did.)
🚀 Code Review Checklist
- Does the list inside
maphave akey? - Is that
keyanindex? (Check if the list is mutable) - Is the
keygenerated on the fly (e.g.,Math.random())? (Fix immediately) - If backend data lacks IDs, did you attach stable IDs when receiving the data?
8.5. Wait, Is Index ALWAYS Bad? (The Exception)
"So I should NEVER use index?" No, you CAN use it if three conditions are met. (According to Robin Pokorny):
- The list and items are static—they are not computed and do not change.
- The items in the list have no ids.
- The list is never reordered or filtered.
Example: A footer menu or a navigation bar.
const MENU = ['Home', 'About', 'Contact'];
{MENU.map((item, index) => <li key={index}>{item}</li>)}
These will likely never change during the user's session. Here, index is fine. Safely ignore the warning.
8.6. Animation & Identity: The Secret Weapon
Keys are also crucial for Animations. If you want to animate an item leaving the list (Framer Motion, React Transition Group), the library MUST know who is leaving.
If you use index:
- Item 0 is removed.
- Item 1 becomes Item 0.
- The library thinks "Item 0 updated, and the last item (Item N) was removed."
- Result: The animation plays on the WRONG item (the last one disappearing), while the first item snaps instantly.
If you use id:
- Item A is removed.
- Library sees "Item A is unmounting".
- Result: Item A gracefully fades out while other items slide up.
Key takeaway: Proper keys are the difference between a "glitchy" UI and a "premium" UI.