State는 모든 종류의 JS 값을 담을 수 있다. 당연히 객체 포함! 그러나 state에서 직접적으로 객체를 바꾸면 안된다. 그렇게 하지 말고, 새로운 객체를 만들어서 (아니면 기존 객체를 copy) 그 사본으로 state를 설정해라!
이 문서에서는..
- state를 업데이트하는 올바른 방법
- 변형 mutation 없이 nested update 하는법
- 불변성 immutability
- Immer 로 객체 사본 덜 만들기
를 알아보자.
state에는 아무 JS 값을 넣을 수 있는데...
const [x, setX] = useState(0);
여태까지는 number, string, boolean 들만 다뤘다. 이런 JS 값들은 "Immutable" 하다. "읽기 전용"이다. - 바꿀 수 없다는 뜻.
값을 바꾸려면 리렌더링을 해야 함.
setX(5);
x
state는 0
에서 5
로 바뀌었지만, 사실 숫자 0
이 바뀐 것은 아니다. number, string, boolean 같은 내장 type들은 바꿀 수가 없음.
이제 state에 객체 object 를 넣을 때를 보자.
const [position, setPosition] = useState({ x: 0, y :0 });
엄밀히 말하면, 객체 그 자체 의 내용을 바꿀 수는 있다. 이것이 바로 mutation.
position.x = 5;
그러나, 리액트 state의 객체들이 엄밀히 mutable 함에도 불구하고, immutable 한 것처럼 다뤄야 한다!! 절대 mutate 직접 바꾸지 말고, replace 해라.
다시 말해, state에 담는 모든 JS 객체는 읽기 전용으로 다루자는 말이다.
아래 예시는 state에 객체를 담는다. 이 객체는 현재 포인터의 위치. 영역에서 커서를 움직이면 빨간 점도 같이 움직여야 하지만, 첫 위치에 가만히 있고 안움직인다.
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0
});
return (
<div
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
style={{
position: 'relative',
width: '100vw',
height: '100vh',
}}>
<div style={{
position: 'absolute',
backgroundColor: 'red',
borderRadius: '50%',
transform: `translate(${position.x}px, ${position.y}px)`,
left: -10,
top: -10,
width: 20,
height: 20,
}} />
</div>
);
}
문제는 바로 요기 :
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
이 코드는 이전 렌더링 에서 position
state에 할당된 객체를 바꾼다. 그러나 setState()
를 사용하지 않으면 리액트는 그 객체가 바뀌었는지 알 수가 없고, 결론적으로 리액트는 암것도 안함. 음식을 다 먹고 나서 주문을 바꾸려고 하는 것과 같다. 가끔 state를 mutate 하는 시도가 작동할 때가 있지만, 리액트는 권장하지 않는다. 렌더 안에서 state는 읽기 전용으로만 접근하는 게 옳다.
새로운 객체를 만들고 setState
인자로 넘겨줄것.
onPointerMove = {e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
setPosition
에서, 리액트에게 이렇게 말해주는 것임 :
position
state를 이 새로운 객체로 replace 해줘이제는 빨간 점이 움직일 것이다
~~새로운 코드는 생략~~이전 예제에서, position
객체는 현재 커서 위치로부터 항상 새롭게 만들어진다. 그러나 새로 만드려는 객체에서 이미 존재하는 데이터를 포함하고 싶을 때가 자주 있을 것임. 예를 들어, form에서 다른 필드는 이전 값들을 가지고 가고, 딱 한 필드 만 업데이트 하고 싶다면?
아래처럼 하면 작동하지 않는다. onChange
핸들러가 상태를 mutate 하기 때문.
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com'
});
function handleFirstNameChange(e) {
person.firstName = e.target.value;
}
function handleLastNameChange(e) {
person.lastName = e.target.value;
}
function handleEmailChange(e) {
person.email = e.target.value;
}
return (
<>
<label>
First name:
<input
value={person.firstName}
onChange={handleFirstNameChange}
/>
</label>
<label>
Last name:
<input
value={person.lastName}
onChange={handleLastNameChange}
/>
</label>
<label>
Email:
<input
value={person.email}
onChange={handleEmailChange}
/>
</label>
<p>
{person.firstName}{' '}
{person.lastName}{' '}
({person.email})
</p>
</>
);
}
(실제로 이게 리액트 엘리먼트는 아니지만, ui만이라도 렌더링 해보면 아래와 같다..)
First name:Last name:Email:Barbara Hepworth (bhepworth@sculpture.com)
세 이벤트 핸들러 중 하나만 예를 들면,
이 코드는 이전 렌더링의 state를 바꾼다.
person.firstName = e.target.value;
정확한 방법은 새로운 객체를 만들고 setPerson
에 인자로 넘기는 것. 그러나 이렇게 객체의 일부 바꾸고 싶은 경우는 기존 데이터를 복사해서 넣어줘야 함 :
setPerson({
firstName: e.target.value, // 새로운 인풋값. keyDown 이벤트마다 업데이트 되겠지
lastName: person.lastName,
email: person.email
});
이렇게 다 쓰는것 보다 ...
← 전개 구문 spread operator 사용해서 모든 프로퍼티를 복사할 필요가 없게끔 편하게 사용할 수 있음.
setPerson({
...person, // 기존 객체 복사
firstname : e.target.value // 이 부분만 override
});
이제 잘 작동한다!
...
spread operator는 "shallow" 얕은 복사라는 것을 기억하자. 이렇게 하면 빠르다. 그러나 nest 된 프로퍼티를 업데이트하고 싶을 때 여러번 사용해야 한다는 말이기도 하다.
실제로는 이렇게 사용한다...
이벤트 핸들러 하나로 여러 필드 업데이트하기
객체를 선언할 때
[
랑]
를 사용하면 동적인 name을 특정할 수 있다.
위와 같은 예시를 하나의 이벤트 핸들러만 사용한다면...function handleChange(e) { setPerson({ ...person, [e.target.name]: e.target.value }); }
이 이벤트 핸들러를 모든
input
의onChange
에 넣어주면 된다.함수에서
[e.target.name]
은input
DOM 요소의name
프로퍼티를 뜻함.
이런 nested object 구조가 있다.
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
person.artwork.city
를 업데이트하고 싶을 때, mutation을 사용하면 이렇게 될 것이다.
person.artwork.city = 'New Delhi';
아까 설명했듯, 리액트에서 state는 immutable 하다!
city
를 바꾸려면, 먼저 새로운 artwork
객체를 만들어야 한다 (이전 데이터가 들어있어야 함).
그리고 나서 새로운 artwork
객체를 가리키는 새로운 person
객체를 만들자 :
const nextArtwork = { ...person.artwork, city : 'New Delhi' };
const nextPerson = { ...person, artork: nextArtwork };
setPerson(nextPerson);
한번에 쓰고 싶으면 :
setPerson({
...person, // 기존 객체 shallow copy
artwork : { // artwork 만 replace 바꾸자!
...person.artwork, // 기존 artwork shallow copy
city : 'New Delhi' // 얘만 업데이트
}
});
복잡해 보여도 잘 작동함.
JS Nested Object 다시보기
객체는 엄밀히 말하면 nest 되지 않는다.
이 객체는 코드로 보기에 "nested" 된것처럼 보이지만:
let obj = { name: 'Niki de Saint Phalle', artwork: { title: 'Blue Nana', city: 'Hamburg', image: 'https://i.imgur.com/Sd1AgUOm.jpg', } };
객체의 작동을 설명할 때 'nesting'은 정확한 표현은 아님.
사실 두 개의 서로 다른 객체를 보고 있는 것임:let obj1 = { title: 'Blue Nana', city: 'Hamburg', image: 'https://i.imgur.com/Sd1AgUOm.jpg', }; let obj2 = { name: 'Niki de Saint Phalle', artwork: obj1 // obj1을 point 하고 있다 };
obj1
객체는obj2
객체 안에 있지 않다. 예를 들어,obj3
은obj1
를 가리킬 수 있다 :let obj1 = { title: 'Blue Nana', city: 'Hamburg', image: 'https://i.imgur.com/Sd1AgUOm.jpg', }; let obj2 = { name: 'Niki de Saint Phalle', artwork: obj1 }; let obj3 = { name: 'Copycat', artwork: obj1 };
obj1
,obj2.artwork
,obj3.artwork
는 사실 다 같은 객체임. 그래서obj3.artwork.city
를 바꾸면obj2.artwork.city
와obj1.city
가 전부 바뀐다.결론 : 객체는 nested 된 것이라기 보다는, 각 객체가 있고 서로를 프로퍼티로 "가리키고" 있는 것이다.
state가 깊게 nested 돼 있다면, 펴는 게 좋다. state의 자료 구조를 역으로 바꾸면 nesting을 피할 수 있다. 하지만, 구조를 바꾸고 싶지 않다면, nested spread 하는 더 빠른 지름길을 선호할 수 있다. Immer는 mutate syntax로 편리하게 작성하게 해 주지만, 내부 코드에서는 복사본을 만들어준다.
updatePerson(draft => {
draft.artwork.city = 'Lagos'; // mutate 아닙니다 Immer 문법임니다
});
mutate 와 달리, 이전 state를 overwrite 하지 않는다!
Immer, 어떻게 작동하나?
Immer 가 제공하는
draft
라는 놈은 Proxy 라는 특별한 객체다. Proxy는 개발자가 거기다 무엇을 하는지 '기록'하는데, 이 때문에 마음껏 state를 mutate 해도 괜찮은 것이다! 코드 아래에서 Immer는draft
의 어느 부분이 바뀌었는지를 찾아내고, 편집을 반영한 완전히 새로운 객체를 생산한다.
Immer를 시도해보자 :
1. npm install use-immer
→ 의존성 추가.
2. import { useState } from 'react'
→ import { useImmer } from 'use-immer'
로 바꿔라.
예시를 봅시다 :
import { useImmer } from 'use-immer';
export default function Form() {
const [person, updatePerson] = useImmer({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
// 이벤트 핸들러들을 보시라.
function handleNameChange(e) {
updatePerson(draft => {
draft.name = e.target.value;
});
}
function handleTitleChange(e) {
updatePerson(draft => {
draft.artwork.title = e.target.value;
});
}
function handleCityChange(e) {
updatePerson(draft => {
draft.artwork.city = e.target.value;
});
}
function handleImageChange(e) {
updatePerson(draft => {
draft.artwork.image = e.target.value;
});
}
return (
<>
<label>
Name:
<input
value={person.name}
onChange={handleNameChange}
/>
</label>
<label>
Title:
<input
value={person.artwork.title}
onChange={handleTitleChange}
/>
</label>
<label>
City:
<input
value={person.artwork.city}
onChange={handleCityChange}
/>
</label>
<label>
Image:
<input
value={person.artwork.image}
onChange={handleImageChange}
/>
</label>
<p>
<i>{person.artwork.title}</i>
{' by '}
{person.name}
<br />
(located in {person.artwork.city})
</p>
<img
src={person.artwork.image}
alt={person.artwork.title}
/>
</>
);
}
이벤트 핸들러들을 보시라. 즉시 mutate 해도 Immer가 알아서 shallow copy를 떠준다고 한다.
useState
와 useImmer
를 얼마든지 섞어 써도 좋다. nested object state가 많아서 코드가 계속 반복될 때 쓰면 좋다.