리액트에서 나는 대가로 배웠다. 여기 적는 실수들은 남 얘기가 아니라 전부 내가 밤을 새워가며 직접 밟은 지뢰다. 공통점이 있다. 문법 에러가 안 난다는 것이다. 코드는 멀쩡히 돌아가는데 화면만 이상하게 나오니, 어디가 틀렸는지 감을 못 잡고 애먼 데만 파게 된다. 그래서 리액트 디버깅은 증상과 원인을 짝지어 외워두는 게 반쯤 실력이다. 자주 나오는 것들부터 하나씩 보자.
key에 배열 인덱스를 쓰지 마라. 목록을 map으로 그릴 때 key를 안 주면 경고가 뜬다. 그래서 초보는 제일 손쉬운 걸 집어넣는다. 인덱스다.
{items.map((item, index) => (
<Row key={index} item={item} />
))}
목록이 고정돼 있으면 티가 안 난다. 사고는 목록의 순서가 바뀌거나 중간에 뭘 지울 때 터진다. key는 리액트가 "이 항목이 아까 그 항목이구나" 하고 알아보는 이름표인데, 인덱스를 쓰면 이름표가 자리 순번에 묶인다. 맨 앞 항목을 지우면 뒤 항목들의 인덱스가 죄다 하나씩 당겨지고, 리액트는 내용이 옆으로 밀린 걸 "0번은 그대로고 마지막이 사라졌네"로 착각한다. 내가 이걸 처음 겪은 건 할 일 목록이었다. 첫 줄을 지웠더니 입력창에 쳐둔 글자들이 한 칸씩 위로 밀려 엉뚱한 줄에 붙어버렸다. 데이터는 멀쩡한데 화면 요소만 뒤섞인 것이다. key에는 그 항목 고유의 id처럼 순서와 무관하게 항목을 따라다니는 값을 줘야 한다.
{items.map((item) => (
<Row key={item.id} item={item} />
))}
의존성 배열을 빼먹으면 옛날 값에 갇힌다. 이게 그 악명 높은 stale closure(낡은 값을 붙든 함수)다. 말은 어렵지만 증상은 단순하다. 화면 숫자는 안 오르는데 뭔가 계속 도는 상황이다.
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
1초마다 숫자가 오를 것 같지만, 실제로는 0에서 1로 딱 한 번 오르고 멈춘다. 의존성 배열이 []라 이 효과는 처음 한 번만 돌고, 그때 만들어진 함수는 그 순간의 count인 0을 영원히 기억한다. 1초마다 setCount(0 + 1)만 되풀이하는 것이다. 고치는 방법은 두 가지다. 이전 값을 인자로 받는 함수형 갱신을 쓰거나, count를 의존성 배열에 넣는 것이다. 이런 경우엔 함수형 갱신이 깔끔하다.
setCount((prev) => prev + 1);
이러면 리액트가 넘겨주는 최신 값을 받아 더하므로, 낡은 count에 갇히지 않는다. 효과 안에서 쓰는 값은 의존성 배열에 넣는다는 원칙만 지켜도 이 부류 버그는 대부분 안 생긴다.
상태를 직접 바꾸면 화면이 안 바뀐다. 배열에 항목을 더하려고 자바스크립트 습관대로 push를 쓰는 사람이 많다.
const [list, setList] = useState([]);
function add(item) {
list.push(item);
setList(list);
}
항목은 배열에 잘 들어가는데 화면은 꿈쩍도 안 한다. 리액트는 상태가 바뀌었는지 판단할 때 값을 하나하나 비교하지 않고, 참조가 달라졌는지만 본다. push는 기존 배열을 그 자리에서 고칠 뿐 새 배열을 만들지 않으니, 리액트 눈엔 setList에 넘어온 배열이 이전과 같은 것으로 보인다. 바뀐 게 없다고 판단해 리렌더를 건너뛰는 것이다. 상태는 늘 새 값으로 갈아끼워야 한다.
setList([...list, item]);
기존 항목을 펼치고 새 항목을 붙인 새 배열을 만들어 넘기면, 참조가 달라졌으니 리액트가 변화를 알아채고 다시 그린다. 객체도 마찬가지다. obj.name = "x"가 아니라 { ...obj, name: "x" }로 새로 만든다. 상태는 건드리지 말고 교체한다고 외워두면 편하다.
렌더 도중에 setState를 부르면 무한 루프다. 이건 브라우저 탭이 하얗게 멈추고 노트북 팬이 도는 종류의 사고다.
function Profile({ user }) {
const [name, setName] = useState("");
setName(user.name);
return <p>{name}</p>;
}
setName을 컴포넌트 본문에 그냥 적어놨다. 렌더 중에 상태를 바꾸면 리렌더가 예약되고, 다시 렌더되며 또 setName이 불리고, 또 리렌더가 예약되고. 끝없이 돈다. 상태 갱신은 렌더 흐름 바깥에서, 즉 이벤트 핸들러나 useEffect 안에서 일어나야 한다. 위 경우처럼 props에서 값을 그대로 가져오는 거라면 애초에 state로 복사할 게 아니라 user.name을 바로 쓰면 된다. 없어도 될 상태를 만든 게 근본 원인인 경우가 의외로 많다.
훅은 조건문이나 반복문 안에서 부르지 마라. useState나 useEffect 같은 훅을 if 안에 넣고 싶은 유혹이 있다.
function Box({ show }) {
if (show) {
const [open, setOpen] = useState(false);
}
// ...
}
리액트는 훅을 이름이 아니라 호출된 순서로 기억한다. 첫 번째 훅, 두 번째 훅 하는 식으로 자리를 매긴다. 그런데 if 안에 넣으면 show가 참일 때와 거짓일 때 훅의 개수와 순서가 달라져, 리액트가 어느 상태가 어느 것인지 헷갈려 한다. 그래서 훅은 항상 컴포넌트 최상단에서, 조건 없이, 늘 같은 순서로 불러야 한다. 조건이 필요하면 훅을 먼저 부르고 그 안에서 갈라야 한다.
function Box({ show }) {
const [open, setOpen] = useState(false);
if (!show) return null;
// ...
}
onClick에 함수를 호출해서 넘기지 마라. 버튼에 핸들러를 다는데 인자를 주고 싶어서 이렇게 적는 경우가 흔하다.
<button onClick={handleClick()}>삭제</button>
이러면 버튼을 누를 때가 아니라 렌더되는 순간 handleClick이 즉시 실행되고, 그 반환값이 onClick에 등록된다. 만약 handleClick 안에서 setState를 한다면, 렌더 도중에 상태를 바꾸는 꼴이 되어 앞서 본 무한 루프로 직행한다. onClick에는 함수 자체를 넘겨야 한다. 인자가 없으면 onClick={handleClick}처럼 괄호 없이 이름만 주고, 인자를 넘겨야 하면 화살표 함수로 감싼다.
<button onClick={() => handleClick(item.id)}>삭제</button>
이렇게 감싸면 클릭하는 순간에야 안쪽 함수가 실행된다. 괄호를 붙이느냐 마느냐의 사소한 차이가 즉시 실행이냐 클릭 시 실행이냐를 가른다.
상태를 바꾼 직후에 읽으면 옛 값이 나온다. 마지막으로 초보를 제일 많이 낚는 함정이다.
function handleClick() {
setCount(count + 1);
console.log(count);
}
setCount로 값을 올렸으니 바로 다음 줄 console.log에 새 값이 찍힐 것 같지만, 옛 값이 그대로 나온다. setCount는 상태를 즉시 바꾸는 게 아니라 "다음 렌더 때 이 값으로 바꿔달라"고 예약만 하기 때문이다. 지금 이 함수가 쥐고 있는 count는 이번 렌더 시점에 고정된 값이라, 예약을 걸어도 그 자리에선 안 변한다. 새 값은 다음 렌더에서 컴포넌트 본문이 다시 실행될 때 count에 담겨 온다. 상태 갱신은 되돌아오는 즉시가 아니라 다음 렌더에 반영된다는 이 감각이, 리액트를 손에 익히는 마지막 관문 같은 것이다. 여기까지 넘기면 화면이 이상한데 에러는 안 나는 상황 앞에서 더는 막막하지 않다. 증상을 보고 원인을 바로 짚게 된다.