React에서 목록을 정렬 버튼으로 바꾸는 기능을 만들다가 화면이 안 바뀌는 버그를 만났다. 범인은 sort()였다.
const [items, setItems] = useState([3, 1, 2]);
// 이렇게 하면 화면이 안 바뀐다
const handleSort = () => {
setItems(items.sort((a, b) => a - b));
};
sort()는 정렬한 새 배열을 돌려주는 게 아니라 원본을 그 자리에서 바꾸고 같은 참조를 돌려준다. setItems에 넘긴 배열이 기존 items와 참조가 똑같으니 React는 "안 바뀌었네" 하고 리렌더를 건너뛴다. reverse(), splice()도 마찬가지로 원본을 건드린다.
그동안은 복사부터 하고 정렬하는 걸로 피했다.
setItems([...items].sort((a, b) => a - b));
동작은 하는데 [...arr] 복사 단계가 눈에 거슬린다. 깜빡하면 바로 버그고.
복사본을 돌려주는 메서드들
그래서 나온 게 원본을 안 건드리고 새 배열을 돌려주는 짝꿍 메서드들이다. 이름 앞에 to가 붙거나 with인 네 개다.
const items = [3, 1, 2];
const sorted = items.toSorted((a, b) => a - b); // [1, 2, 3]
const reversed = items.toReversed(); // [2, 1, 3]
const replaced = items.with(0, 99); // [99, 1, 2] (인덱스 0을 99로)
const spliced = items.toSpliced(1, 1); // [3, 2] (인덱스 1부터 1개 삭제)
console.log(items); // [3, 1, 2] ← 원본 그대로
items는 네 번을 거쳐도 그대로다. 아까 React 코드는 이제 복사 없이 끝난다.
const handleSort = () => {
setItems((prev) => prev.toSorted((a, b) => a - b));
};
특히 with(index, value)가 유용하다. 배열의 특정 원소 하나만 바꾼 새 배열이 필요할 때, 예전엔 map으로 인덱스 비교하는 코드를 썼는데 이제 한 줄이다.
// 체크박스 하나만 토글
setTodos((prev) => prev.with(i, { ...prev[i], done: !prev[i].done }));
쓸 때 알아둘 것
toSorted는 얕은 복사라 객체 배열이면 안쪽 객체는 같은 참조를 공유한다. 그래서 위 with 예시처럼 바꾸는 객체는 { ...prev[i] }로 따로 복사해줘야 한다.
지원 범위는 넉넉하다. Node 20+, 그리고 2023년 중반 이후 크롬·사파리·파이어폭스에서 다 돌아가는 Baseline이라 요즘 프로젝트면 폴리필 없이 바로 써도 된다.
결론은 간단하다. 상태나 props로 받은 배열을 정렬·뒤집기·수정할 일이 생기면 sort/reverse/splice 대신 to가 붙은 쪽을 쓰자. [...arr] 복사를 깜빡해서 생기던 리렌더 버그 한 종류가 통째로 사라진다.