React key와 index: 목록 입력값이 뒤섞이는 문제
할 일 목록에서 줄마다 메모를 적는 입력칸이 있습니다.
function TodoList({ todos, onRemove }) {
return (
<ul>
{todos.map((todo, index) => (
<TodoRow key={index} todo={todo} onRemove={onRemove} />
))}
</ul>
);
}
function TodoRow({ todo, onRemove }) {
const [memo, setMemo] = useState("");
return (
<li>
{todo.title}
<input value={memo} onChange={(e) => setMemo(e.target.value)} />
<button onClick={() => onRemove(todo.id)}>삭제</button>
</li>
);
}첫 줄 「견적서 보내기」의 메모칸에 「오늘 오후」라고 적고, 첫 줄을 삭제합니다. 목록에는 「견적서 보내기」가 사라지고 둘째 줄이던 「샘플 발송」이 맨 위로 올라옵니다. 그런데 「샘플 발송」 옆 메모칸에 「오늘 오후」가 적혀 있습니다.
key 는 경고를 없애는 값이라는 생각
목록을 map 으로 그리면 React 가 콘솔에 key 가 없다는 경고를 냅니다. 그래서 key 를 흔히 「경고를 끄기 위해 넣는 값」, 무엇이든 서로 다르기만 하면 되는 값으로 이해합니다. 가장 손쉬운 값이 map 의 두 번째 인자인 index 입니다. 경고가 사라지고, 화면도 잘 나옵니다.
이 이해로는 메모가 엉뚱한 줄로 옮겨 간 이유가 설명되지 않습니다. key 는 모두 달랐고, 경고도 없었습니다. key 의 역할은 서로 다르기만 한 것이 아니었습니다.
state 는 컴포넌트가 아니라 트리의 자리에 붙는다
먼저 메모가 어디에 저장돼 있는지부터 봐야 합니다. memo 는 TodoRow 안의 useState 로 만들었으니 그 컴포넌트가 갖고 있다고 생각하기 쉽습니다. Preserving and Resetting State 문서는 다르게 설명합니다.
React preserves a component's state for as long as it's being rendered at its position in the UI tree.
state 를 실제로 들고 있는 것은 React 이고, React 는 그 state 를 렌더 트리의 자리에 연결해 둡니다. 다음 렌더링에서 같은 자리에 같은 종류의 컴포넌트가 오면, 그 컴포넌트가 지난번 자리의 state 를 이어받습니다.
그럼 목록 안에서 「같은 자리」는 무엇으로 정해질까요. 이것을 정하는 것이 key 입니다.
key 는 형제 사이에서 항목을 알아보는 이름표
Rendering Lists 문서는 key 를 폴더 안의 파일 이름에 비유합니다. 파일에 이름이 없고 「첫 번째 파일」, 「두 번째 파일」로만 부른다면, 첫 파일을 지우는 순간 두 번째 파일이 첫 번째가 되어 헷갈리게 된다는 설명입니다.
File names in a folder and JSX keys in an array serve a similar purpose. They let us uniquely identify an item between its siblings.
React 는 렌더링이 끝나면 지난번 목록과 이번 목록을 key 로 맞춰 봅니다. key 가 같은 항목은 같은 항목으로 보고 state 와 DOM 을 이어 줍니다. 지난번에 있던 key 가 없으면 그 항목을 지우고, 새 key 가 생기면 새로 만듭니다.
index 를 key 로 쓰면 이름표가 자리를 따라간다
처음의 목록에 대입해 보겠습니다. 삭제 전과 후의 key 는 이렇습니다.
| key | 삭제 전 | 삭제 후 |
|---|---|---|
| 0 | 견적서 보내기 (메모: 오늘 오후) | 샘플 발송 |
| 1 | 샘플 발송 | 거래처 방문 |
| 2 | 거래처 방문 | 없음 |
React 가 보기에 key 0 은 삭제 전에도 있었고 삭제 후에도 있습니다. 그래서 key 0 자리의 TodoRow 를 그대로 이어 쓰고, props 의 todo 만 「샘플 발송」으로 바꿉니다. 메모 state 는 key 0 자리에 붙어 있으니 「오늘 오후」가 남습니다. 사라진 것으로 판단되는 것은 key 2, 즉 마지막 줄입니다.
사용자가 지운 것은 첫 줄인데, React 는 마지막 줄을 지우고 나머지 줄의 내용을 한 칸씩 당겨 쓴 것으로 처리했습니다. index 는 항목의 이름이 아니라 자리의 번호라서, 순서가 바뀌면 이름표가 항목이 아니라 자리를 따라갑니다. 문서도 이 점을 짚습니다.
But the order in which you render items will change over time if an item is inserted, deleted, or if the array gets reordered. Index as a key often leads to subtle and confusing bugs.
「subtle」이라는 단어가 정확합니다. props 로만 그리는 줄은 내용이 바로 바뀌어서 겉보기에 멀쩡합니다. 줄 안에 state 가 있거나, 입력칸의 포커스와 커서 위치, 펼침 상태, 애니메이션처럼 DOM 이 들고 있는 상태가 있을 때만 어긋남이 드러납니다.
데이터에서 온 안정된 id 를 쓰는 이유
해법은 항목 자체에 붙은 값을 key 로 쓰는 것입니다.
{todos.map((todo) => (
<TodoRow key={todo.id} todo={todo} onRemove={onRemove} />
))}이제 「견적서 보내기」를 지우면 그 id 가 목록에서 사라지므로 React 는 그 줄과 그 줄의 메모 state 를 함께 지웁니다. 「샘플 발송」은 자기 id 로 자기 state 를 그대로 이어받습니다.
문서가 정리한 key 의 규칙은 둘입니다. 형제 사이에서 유일해야 하고, 바뀌지 않아야 합니다. 서버 데이터라면 데이터베이스 id 를 쓰고, 브라우저에서 새로 만드는 항목이라면 만들 때 crypto.randomUUID() 나 증가하는 번호로 id 를 붙여 두면 됩니다. 중요한 것은 만들 때 한 번 붙인다는 점입니다.
그래서 반대 방향의 실수도 있습니다.
<TodoRow key={Math.random()} ... />렌더링할 때마다 key 가 새로 만들어지면 React 는 매번 모든 줄을 처음 보는 항목으로 판단합니다. 문서의 표현대로 모든 컴포넌트와 DOM 이 매번 새로 만들어져 느리고, 입력하던 내용도 렌더링마다 사라집니다. index 가 이름표를 자리에 묶는다면, 무작위 key 는 이름표를 매번 바꿔 다는 셈입니다.
index 가 괜찮은 경우도 있습니다. 문서의 예처럼 시의 행처럼 순서가 절대 바뀌지 않고 추가나 삭제도 없는 목록입니다. 다만 저는 「지금은 바뀌지 않는다」가 「앞으로도 바뀌지 않는다」를 보장하지 않으므로, id 가 있다면 id 를 쓰는 편이 안전하다고 봅니다.
목록 밖에서 key 로 state 를 초기화하는 방법
key 가 「이 자리의 컴포넌트가 같은 것인가」를 정한다는 점을 이용하면 목록이 아닌 곳에서도 쓸 수 있습니다. 같은 문서가 이 용도를 따로 소개합니다.
<ChatInput key={contactId} contactId={contactId} />대화 상대를 바꿀 때 입력하던 메시지가 남으면 안 되는 화면입니다. contactId 를 key 로 주면, 상대가 바뀔 때 React 는 다른 컴포넌트로 판단해 기존 것을 지우고 새로 만듭니다. 입력칸 state 가 초기화됩니다. 효과로 「상대가 바뀌면 state 를 비운다」를 짜는 것보다 단순하고, 한 렌더링 동안 옛 값이 보이는 순간도 없습니다.
이 힘은 반대로 비용이 되기도 합니다. key 에 자주 바뀌는 값을 넣으면 그때마다 컴포넌트가 지워지고 새로 만들어집니다. 지도 컴포넌트의 key 에 좌표를 넣었다가 확대할 때마다 지도가 통째로 다시 만들어진 사례를 Leaflet 지도가 느려지는 3가지 원인에 적어 두었습니다. key 에는 「이 값이 바뀌면 완전히 다른 것으로 봐야 하는가」에 예라고 답할 수 있는 값만 넣습니다.
여기까지가 확실한 부분
state 가 트리의 자리에 붙는다는 점, key 의 두 규칙, index 와 무작위 key 에 대한 경고, key 로 state 를 초기화하는 방법은 react.dev 문서 기준입니다. key 는 컴포넌트에 prop 으로 전달되지 않으므로, 컴포넌트 안에서 id 가 필요하면 todo.id 처럼 따로 넘겨야 합니다. 목록을 비교하는 내부 알고리즘의 세부 동작은 문서가 보장하는 범위가 아니라서 이 글에서 다루지 않았습니다.
함께 읽기
- React useMemo와 useCallback은 언제 써야 할까?리뷰에서 이런 코드를 자주 만납니다.
- React Error Boundary와 try/catch의 차이대시보드에 위젯이 여섯 개 있습니다. 그중 매출 차트 위젯이 서버에서 예상과 다른 모양의 데이터를 받아 렌더링 중에 오류를 냅니다.
- React useActionState와 useOptimistic으로 폼 다루기견적 요청 폼을 보내는 코드입니다.
- React 커스텀 훅은 state를 공유할까?장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.
- React Context와 리렌더링로그인한 사용자 정보와 장바구니를 앱 어디서나 쓰려고 컨텍스트 하나를 만들었다고 해 보겠습니다.