[react/typescript] Component key value 에 id 를 넣을 때의 주의점

minky·2022년 3월 27일
1
post-thumbnail

리액트 element 는 불변객체이다. 공식 docs 의 표현을 그대로 빌려 "엘리먼트는 영화에서 하나의 프레임과 같이 특정 시점의 UI를 보여" 주는데, 이 특정 시점의 UI가 나타내는 데이터가 array 객체일 경우 map 함수 등 반복문을 통해 그 구현이 보통은 이루어진다. 이 때 그려지는 각 component는 (리)렌더링시 시간복잡도를 낮추기 위해 서로 다른 key 값을 명시적으로 넣어주어야 한다(react diffing algorithm 참조).

key 값은 같은 자식 층위에서 유일해야 한다는 조건을 만족시키기 위해 객체가 id 와 같은 property 를 가지고 있는 경우 child.id 같이 key 값을 넣을 때가 많다. 이는 대체로 옳지만 아주 가끔 어이없는 사용자 경험을 심어줄 수 있다.

리액트는 data를 fetch해온 후 해당 data와 상호작용이 필요하다면 state로 이를 관리해야 한다(global state든 context든 hook이든 마찬가지). 이 원칙 때문에 문제가 되는 경우는이 원칙 덕분에 문제를 발견하는 경우는

  1. id를 prop으로 가지는 array objet를 state에 넣고
  2. 이를 map함수로(for도 마찬가지), 그리면서 key에 id를 넣은 후
  3. child를 setState를 통해 변화시키는 경우이다.

현재 동물병원에 가입한 반려동물 리스트를 보여주고, 여기에 관리자가 고객 요청에 따라 반려동물의 이름을 변경할 수 있는 기능이 있다고 가정해보자.

interface Animal {
  id: number
  name: string
}

const animals: Animal[] = [
  {
    id: 1,
    name: '나비',
  },
  {
    id: 2,
    name: '코코',
  },
  {
    id: 3,
    name: '야옹',
  },
]

import { ChangeEvent, useState } from 'react'
import { StyledInput } from 'src/styles'

// ssr로 위와 같은 data를 props로 넘겨받았다고 치고,
const AnimalList = ({ animals: initialAnimals }: { animals: Animal[] }): JSX.Element => {
  const [animals, setAnimals] = useState<Animal[]>(initialAnimals)

  const changeAnimalName = (index: number, id: number, name: string) => {
    setAnimals((a) => {
      const newAnimals = a.slice()
      newAnimals[index] = { id, name }
      return newAnimals
    })
  }

  return (
    <div>
      {animals.length ? (
        animals.map((animal, index) => (
          <AnimalItem animal={animal} index={index} changeAnimalName={changeAnimalName} key={animal.id} />
        ))
      ) : (
        <div />
      )}
    </div>
  )
}

const AnimalItem = ({
  animal,
  index,
  changeAnimalName,
}: {
  animal: Animal
  index: number
  changeAnimal: (index: number, id: number, name: string) => void
}): JSX.Element => {
  const onChange = (e: ChangeEvent<HTMLInputElement>) => {
	changeAnimalName(index, animal.id, e.target.value)
  }

  return <StyledInput value={animal} onChange={onChange} />
}

export default AnimalList

AnimalItem은 view뿐만 아니라 edit 역시 목적으로 한다. 그렇기에 특정 동작을 통한 POST 전까지 자유롭게 수정이 가능해야 하는데 이런, name을 한글자 누를 때마다 focus가 풀린다. 갑자기 왠 blur인가 싶어 onBlur에 console을 찍어도 아무것도 안잡힌다. onChange를 useCallback으로 감싸지 않아서 그런가..? 라는 희망어린 생각도 들지만 index도 id도 변하지 않는데 의존성 면에서 아무런 상관이 없다. 범인은 위에서 언급한대로 key에 넣은 animal.id 이다.

animal.id는 변하지 않지만 animal.name이 변함으로서 AnimalItem component가 갱신된다. 리액트는 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신하고, 따라서 key값 역시 갱신됨에 따라(animal.id) StyledInput에 있던 focus 역시 풀리게 된다. 즉 해당 component는 매 keyPress 마다 key value를 같은 값으로 갱신하고 있다.
이를 방지하기 위해 key에 animal_${index}와 같은 rerender props와 상관없는 값을 넣어 응당 기대되는 사용자 경험을 지속할 수 있다.

사실 key에 index를 넣는 것을 react는 권장하지 않는다. 배열의 index를 key로 넣는 것을 '최후의 수단'이라고 까지 docs는 표현한다. 배열이 재배열되는 경우(추가, 삭제 등의 이유로) state가 key를 따라감에 따라 아주 엉망인 상태관리를 경험할 수 있다. 자세한 사항은 react docs에 reconciliation을 다시 읽어보도록 하자.

(key=index일 경우 잘못된 사용 예: https://codepen.io/pen?&editors=0010&layout=left, key=id를 사용해 해결한 예: https://codepen.io/pen?&editors=0010&layout=left)

그러나 때에 따라 무조건 datum의 유일한 prop이 아닌 그 외의 방법도 필요할 수 있다는 것을 깨닫는 계기가 되었다. 끝!

참고 자료
리액트 한국어판 공식문서:
엘리먼트 렌더링 - https://ko.reactjs.org/docs/rendering-elements.html,
재조정 - https://ko.reactjs.org/docs/reconciliation.html

내 뇌피셜

profile
프로덕트가 발전하는 과정을 즐기는 개발자입니다.

0개의 댓글