불변성 — 왜 새로 만들어야 하나
Article
9강에서 규칙 하나를 던져뒀다. state를 직접 고치지 말고 새로 만들어라.
번거롭다. user.age = 28 한 줄이면 될 것을 { ...user, age: 28 }이라고 쓴다. 배열에 하나 넣는데 push 대신 [...items, item]을 쓴다.
왜 이래야 하는지 끝까지 파고든다.
React 는 주소만 본다
setState를 부르면 React는 새 값과 이전 값을 비교한다. 정말 바뀌었는지 확인해야 헛되이 다시 그리지 않는다.
비교 방법은 Object.is다. 자바스크립트 편 17강에서 본 ===와 거의 같다.
const a = { count: 1 };
const b = { count: 1 };
console.log(a === b);
- false
내용이 똑같은데 다르다고 나온다. 객체 비교는 내용이 아니라 주소를 보기 때문이다.
이 성질이 React에서는 이렇게 뒤집힌다.
const user = { name: "지원", age: 27 };
const same = user;
same.age = 28;
console.log(user === same);
- true
값을 고쳤는데 같다고 한다. 주소가 그대로니까.
이전 값과 새 값이 같은 주소를 가리킨다
- prevState
- 0x1a2b
- { name: '지원', age: 28 }
- nextState
- 0x1a2b
- 같은 객체다
React는 Object.is(prev, next)가 true이므로 "안 바뀌었네" 하고 리렌더링을 건너뛴다. 화면이 그대로다.
새로 만들면 알아챈다
setUser({ ...user, age: 28 });
{ ...user }가 새 객체를 만든다. 주소가 다르니 Object.is가 false를 주고, React가 다시 그린다.
주소가 다르므로 변화를 알아챈다
- prevState
- 0x1a2b
- { name: '지원', age: 27 }
- nextState
- 0x7f3c
- { name: '지원', age: 28 }
배열 갱신 패턴
자바스크립트 편 15강에서 원본을 바꾸는 메서드와 안 바꾸는 메서드를 구분했다. 그 표가 그대로 쓰인다.
| 하려는 일 | 쓰면 안 되는 것 | 써야 하는 것 |
|---|---|---|
| 끝에 추가 | arr.push(x) | [...arr, x] |
| 앞에 추가 | arr.unshift(x) | [x, ...arr] |
| 삭제 | arr.splice(i, 1) | arr.filter(…) |
| 수정 | arr[i] = x | arr.map(…) |
| 정렬 | arr.sort() | [...arr].sort() |
| 뒤집기 | arr.reverse() | [...arr].reverse() |
실제로 쓰면 이렇다.
const [todos, setTodos] = useState([]);
function add(text) {
setTodos(prev => [...prev, { id: crypto.randomUUID(), text, done: false }]);
}
function remove(id) {
setTodos(prev => prev.filter(todo => todo.id !== id));
}
function toggle(id) {
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
)
);
}
toggle을 눈여겨본다. map 안에서 바꿔야 할 항목만 새 객체로 만들고, 나머지는 원래 객체를 그대로 돌려준다.
- 01
새 배열이 만들어진다
map은 항상 새 배열을 반환한다. 배열의 주소가 달라진다. - 02
바뀐 항목만 새 객체
조건에 맞는 하나만
{ ...todo, done: … }로 새로 만든다. - 03
나머지는 그대로
같은 객체를 그대로 넣는다. 주소가 같으니 React 가 "이건 안 바뀌었네" 하고 건너뛴다.
todo.id === id ? { ...todo, done: !todo.done } : todo
바뀌지 않은 항목의 주소를 유지하는 게 중요하다. 전부 새로 만들면 React가 전부 다시 그린다.
중첩이 깊어지면
여기가 진짜 골치다.
const [user, setUser] = useState({
name: "지원",
address: { city: "서울", zip: "04524" },
});
city만 바꾼다고 해보자.
setUser({ ...user, address: { ...user.address, city: "부산" } });
user도 새로, address도 새로 만들어야 한다. 자바스크립트 편 18강에서 본 얕은 복사의 결과다. { ...user }는 address의 주소만 복사한다.
세 겹이면 이렇게 된다.
setState({
...state,
a: { ...state.a, b: { ...state.a.b, c: 1 } },
});
읽기 힘들고 틀리기 쉽다. 답은 문법을 익히는 것이 아니라 구조를 바꾸는 것이다.
스프레드를 겹쳐 쓴다
구조는 그대로
- 깊어질수록 코드가 길어진다
- 한 겹만 빠뜨려도 조용히 실패
- 읽는 사람이 구조를 외워야 한다
state 를 평평하게
구조를 고친다
address를 별도 state 로 분리- 목록은
id를 키로 한 객체로 - 갱신 코드가 한 줄이 된다
왜 이 규칙이 이득인가
번거로움을 지불하고 무엇을 얻는지 정리한다.
| 얻는 것 | 설명 |
|---|---|
| 빠른 비교 | 주소 하나만 보면 된다 |
| 예측 가능성 | 이전 상태가 절대 안 바뀌므로 추적할 수 있다 |
| 최적화 가능 | React.memo 가 props 비교로 리렌더링을 건너뛴다 |
| 실행 취소 | 이전 상태들을 그대로 보관하면 undo 가 공짜다 |
세 번째는 실전에서 크다. 자식 컴포넌트가 받은 props의 주소가 그대로면, React는 자식을 아예 다시 실행하지 않을 수 있다. 직접 고치는 방식에서는 불가능하다.
props 도 마찬가지다
5강에서 "props는 읽기 전용"이라고 했다. 같은 이유다.
function ProductCard({ product }) {
product.price = 0;
}
부모의 객체가 진짜로 바뀌는데, 부모는 자기 state가 바뀐 줄 모르니 다시 그리지 않는다. 데이터와 화면이 어긋난다. 1강에서 React가 없애려던 바로 그 문제다.
정리하면
- React는 내용이 아니라 주소를 비교한다.
Object.is하나로 끝내야 빠르기 때문이다. - 그래서 state를 직접 고치면 주소가 그대로라 못 알아챈다.
- 항상 새 객체·새 배열을 만든다.
push,splice,sort,reverse를 쓰지 않는다. map으로 갱신할 때 바뀐 항목만 새로 만들고 나머지는 그대로 둔다.- 스프레드는 얕은 복사다. 중첩된 것도 각각 새로 만들어야 한다.
- 세 겹 넘게 겹친다면 state 구조를 평평하게 고친다.
- 불변성 덕분에 빠른 비교, 예측 가능성, 최적화, 실행 취소를 얻는다.
Part 3이 끝났다. 다음 강의부터 렌더링 원리다. 지금까지 "React가 다시 그린다"고만 했던 그 일이 정확히 무엇인지 본다.