State에서 객체 업데이트
State는 객체를 포함한 모든 종류의 자바스크립트 값을 저장할 수 있습니다. 하지만 React state에 있는 객체를 직접 변경해서는 안 됩니다. 대신 객체를 업데이트하려면 새 객체를 생성하거나 기존 객체의 복사본을 만든 다음 해당 복사본을 사용하도록 state를 설정해야 합니다.
학습 내용
- React 상태에서 객체를 올바르게 업데이트하는 방법
- 중첩된 객체를 변경하지 않고 업데이트하는 방법
- 불변성이란 무엇이고 어떻게 깨뜨리지 않는가?
- Immer로 객체 복사를 덜 반복적으로 만드는 방법
변이(mutation)란 무엇인가요?
모든 종류의 자바스크립트 값은 상태에 저장할 수 있습니다.
const [x, setX] = useState(0);
지금까지 number, string, boolean으로 작업했었습니다. 이러한 종류의 자바스크립트 값은 "불변", 즉 변경할 수 없거나 "읽기 전용"입니다. 다시 렌더링을 트리거하여 값을 바꿀 수 있습니다:
setX(5);
x 상태가 0에서 5로 변경되었지만 숫자 0 자체는 변경되지 않았습니다.
자바스크립트에서는 number, string, boolean과 같은 원시값을 변경할 수 없습니다.
이제 상태 속 객체를 생각해 봅시다:
const [position, setPosition] = useState({ x: 0, y: 0 });
기술적으로는 객체 자체의 내용을 변경할 수 있습니다. 이를 '변이' (mutation) 라고 합니다:
position.x = 5;
그러나 React 상태의 객체는 기술적으로 변경 가능하지만 number, boolean, string과 같이 불변하는 것처럼 취급해야 합니다. 객체를 변경하는 대신 항상 '교체'해야 합니다.
상태를 읽기 전용으로 처리
다시 말해, 상태에 넣은 모든 JavaScript 객체를 읽기 전용으로 취급해야 합니다.
이 예제에서는 현재 포인터 위치를 나타내는 객체를 상태로 유지합니다. 미리보기 영역을 터치하거나 커서를 이동하면 빨간색 점이 움직여야 합니다. 그러나 점은 초기 위치에 유지됩니다:
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;
}}
이 코드는 이전 렌더링에서 위치에 할당된 객체를 수정합니다. 하지만 state setter 함수를 사용하지 않으면 React는 객체가 변경되었다는 사실을 알지 못합니다. 따라서 React는 아무 반응도 하지 않습니다. 이미 음식을 다 먹은 후에 주문을 바꾸려고 하는 것과 같습니다. 경우에 따라 작동할 수 있지만 권장하지 않습니다. 렌더링에서 접근할 수 있는 state 값은 읽기 전용으로 취급해야 합니다.
이 경우 실제로 리렌더링을 트리거하려면 새 오브젝트를 생성하여 state setter 함수에 전달합니다:
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
setPosition을 사용하면 React에 이렇게 지시하는것과 같습니다:
- 위치를 이 새 객체로 바꾸세요.
- 이 컴포넌트를 다시 렌더링합니다.
이제 미리보기 영역을 터치하거나 마우스를 가져가면 빨간색 점이 포인터를 따라가는 것을 볼 수 있습니다:
import { useState } from 'react';
export default function MovingDot() {
const [position, setPosition] = useState({
x: 0,
y: 0
});
return (
<div
onPointerMove={e => {
setPosition({
x: e.clientX,
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>
);
}
Deep Dive - 로컬 변이는 괜찮습니다
이와 같은 코드는 상태의 기존 객체를 수정하기 때문에 문제가 됩니다:
position.x = e.clientX;
position.y = e.clientY;
하지만 이와 같은 코드는 방금 생성한 새 객체를 변경하는 것이므로 전혀 문제가 없습니다:
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
사실 이렇게 작성하는 것과 완전히 동일합니다:
setPosition({
x: e.clientX,
y: e.clientY
});
변이는 이미 state인 기존 객체를 변경할 때만 문제가 됩니다. 방금 생성한 객체를 변경해도 다른 코드가 아직 참조하지 않으므로 괜찮습니다. 객체를 변경해도 해당 객체에 종속된 다른 객체에 실수로 영향을 미치지 않습니다. 이를 "로컬 변이"이라고 합니다. 렌더링하는 동안에도 로컬 변형을 수행할 수 있습니다. 매우 편리하고 완전히 괜찮습니다!
스프레드 구문을 사용하여 객체 복사하기
이전 예제에서 위치 개체는 항상 현재 커서 위치에서 새로 만들어졌습니다. 그러나 종종 기존 데이터를 새로 만드는 개체의 일부로 포함시키고 싶을 때가 있습니다. 예를 들어 양식에서 하나의 필드만 업데이트하고 다른 모든 필드에 대해 이전 값을 유지하려는 경우가 있습니다.
이러한 입력 필드는 onChange 핸들러가 상태를 변경하기 때문에 작동하지 않습니다:
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>
</>
);
}
예를 들어 이 줄은 과거 렌더링의 상태를 변경합니다:
person.firstName = e.target.value;
원하는 동작을 얻을 수 있는 가장 안정적인 방법은 새 객체를 생성하고 이를 setPerson에 전달하는 것입니다.
하지만 여기서는 필드 중 하나만 변경되었으므로 기존 데이터도 복사려고 합니다:
setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});
모든 속성을 개별적으로 복사할 필요가 없도록 ... 스프레드 구문을 사용할 수 있습니다.
setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});
이제 폼이 작동합니다!
각 입력 필드에 대해 별도의 상태 변수를 선언하지 않은 것을 주목하세요. 큰 양식의 경우 올바르게 업데이트하기만 하면 모든 데이터를 객체에 그룹화하여 보관하는 것이 매우 편리합니다!
스프레드 구문은 "얕은" 구문으로, 한 단계 깊이까지만 복사한다는 점에 유의하세요. 따라서 속도는 빠르지만 중첩된 프로퍼티를 업데이트하려면 두 번 이상 사용해야 한다는 의미이기도 합니다.
Deep Dive - 여러 필드에 단일 이벤트 핸들러 사용
객체 정의 내에서 [ ] 중괄호를 사용하여 동적 이름을 가진 프로퍼티를 지정할 수도 있습니다. 다음은 동일한 예시이지만 세 개의 다른 이벤트 핸들러 대신 하나의 이벤트 핸들러를 사용한 예시입니다:
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com'
});
function handleChange(e) {
setPerson({
...person,
[e.target.name]: e.target.value
});
}
return (
<>
<label>
First name:
<input
name="firstName"
value={person.firstName}
onChange={handleChange}
/>
</label>
<label>
Last name:
<input
name="lastName"
value={person.lastName}
onChange={handleChange}
/>
</label>
<label>
Email:
<input
name="email"
value={person.email}
onChange={handleChange}
/>
</label>
<p>
{person.firstName}{' '}
{person.lastName}{' '}
({person.email})
</p>
</>
);
}
여기서 e.target.name은 <input> DOM 요소에 지정된 이름 속성을 참조합니다.
중첩된 개체 업데이트하기
다음과 같은 중첩된 객체 구조를 생각해 보세요:
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';
하지만 React에서는 상태를 불변으로 취급합니다! city를 변경하려면 먼저 새 artwork 객체(이전 아트웍의 데이터로 미리 채워진)를 생성한 다음 새 artwork를 가리키는 새 person 객체를 생성해야 합니다:
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
또는 단일 함수 호출로 작성할 수도 있습니다:
setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});
약간 장황해지긴 하지만 대부분의 경우 잘 작동합니다:
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
function handleNameChange(e) {
setPerson({
...person,
name: e.target.value
});
}
function handleTitleChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
title: e.target.value
}
});
}
function handleCityChange(e) {
setPerson({
...person,
artwork: {
...person.artwork,
city: e.target.value
}
});
}
function handleImageChange(e) {
setPerson({
...person,
artwork: {
...person.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}
/>
</>
);
}
Deep Dive - 객체는 실제로 중첩되지 않습니다.
이와 같은 객체는 코드에서 "중첩"되어 나타납니다:
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};
그러나 '중첩'은 객체의 동작 방식을 생각하는 부정확한 방식입니다. 코드가 실행될 때 "중첩된" 객체 같은 것은 존재하지 않습니다. 실제로는 서로 다른 두 개의 객체를 보고 있는 것입니다:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
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
};
obj3.artwork.city를 변경하면 obj2.artwork.city와 obj1.city 모두에 영향을 미칩니다. obj3.artwork, obj2.artwork, obj1이 동일한 객체이기 때문입니다. 객체를 "중첩된" 객체라고 생각하면 이 점을 이해하기 어렵습니다. 대신 프로퍼티를 사용하여 서로를 '가리키는' 별도의 객체입니다.
Immer로 간결한 업데이트 로직 작성
상태가 깊게 중첩된 경우, 상태를 평탄화 하는 것을 고려할 수 있습니다. 하지만 상태 구조를 변경하고 싶지 않다면 중첩 스프레드보다 지름길을 선호할 수 있습니다. Immer는 편리하지만 변경 가능한 구문을 사용하여 작성하고 사본 생성을 자동으로 처리하는 인기 라이브러리입니다. Immer를 사용하면 작성하는 코드가 "규칙을 깨고" 객체를 변경하는 것처럼 보입니다:
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
하지만 일반 mutation과 달리 과거 상태를 덮어쓰지 않습니다!
Deep Dive - immer는 어떻게 동작할까요?
Immer에서 제공하는 draft는 프록시라고 하는 특수한 유형의 객체로, 사용자가 하는 작업을 "기록"합니다. 그렇기 때문에 원하는 만큼 자유롭게 수정할 수 있습니다! Immer는 draft에서 어떤 부분이 변경되었는지 파악한 후 편집 내용이 포함된 완전히 새로운 객체를 생성합니다.
Immer를 사용해 보려면:
- npm install use-immer를 실행하여 Immer를 종속성으로 추가합니다.
- 그런 다음 'react'에서 import { useState }를 'use-immer'에서 import { useImmer }로 바꿉니다.
다음은 위의 예제를 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}
/>
</>
);
}
이벤트 핸들러가 얼마나 간결해졌는지 주목하세요. 단일 컴포넌트에서 useState와 useImmer를 원하는 만큼 믹스 앤 매치할 수 있습니다. 특히 상태에 중첩이 있고 객체를 복사하면 코드가 반복되는 경우 업데이트 핸들러를 간결하게 유지하는 데 Immer는 좋은 방법입니다.
Deep Dive -React에서 상태 변형을 권장하지 않는 이유는 무엇인가요?
몇 가지 이유가 있습니다:
- 디버깅: console.log를 사용하고 상태를 변경하지 않으면 최근의 상태 변경으로 인해 과거 로그가 가려지지 않습니다. 따라서 렌더링 사이에 상태가 어떻게 변경되었는지 명확하게 확인할 수 있습니다.
- 최적화: 일반적인 React 최적화 전략은 이전 props나 state가 다음 props와 동일한 경우 작업을 건너뛰는 것에 의존합니다. state를 변경하지 않는다면 변경이 있었는지 확인하는 것이 매우 빠릅니다. 만약 prevObj === obj라면, 객체 내부에 변경된 것이 없다는 것을 확신할 수 있습니다.
- 새로운 기능: 우리가 개발 중인 새로운 React 기능은 상태를 스냅샷처럼 취급하는 데 의존합니다. 과거 버전의 state를 변경하는 경우 새로운 기능을 사용하지 못할 수 있습니다.
- 요구사항 변경: 실행 취소/다시 실행 구현, 변경 내역 표시, 사용자가 양식을 이전 값으로 재설정할 수 있도록 하는 것과 같은 일부 애플리케이션 기능은 아무것도 변경되지 않은 상태일 때 더 쉽게 수행할 수 있습니다. 과거의 상태 복사본을 메모리에 보관하고 필요할 때 재사용할 수 있기 때문입니다. 변경 접근 방식으로 시작하면 나중에 이와 같은 기능을 추가하기 어려울 수 있습니다.
- 더 간단한 구현: React는 mutation에 의존하지 않기 때문에 객체에 특별한 작업을 할 필요가 없습니다. 많은 "반응형" 솔루션처럼 프로퍼티를 가로채거나, 항상 프록시로 래핑하거나, 초기화할 때 다른 작업을 할 필요가 없습니다. 이것이 바로 React가 추가적인 성능이나 정확성의 함정 없이 아무리 큰 객체라도 상태에 넣을 수 있는 이유이기도 합니다.
실제로는 React에서 상태 변경을 "피할" 수 있지만, 이 접근 방식을 염두에 두고 개발된 새로운 React 기능을 사용할 수 있도록 그렇게 하지 말 것을 강력히 권장합니다. 미래의 기여자들과 여러분의 미래의 자신도 고마워할 것입니다!
요약
- React의 모든 state는 불변으로 취급하세요.
- state에 객체를 저장하면 객체를 변경해도 렌더링이 트리거되지 않고 이전 렌더링 "스냅샷"의 상태가 변경됩니다.
- 객체를 변경하는 대신 새 버전을 생성하고 state를 설정하여 다시 렌더링을 트리거하세요.
- 오브젝트 스프레드 구문 {...obj, something: 'newValue'}를 사용하여 오브젝트의 복사본을 만들 수 있습니다.
- 스프레드 구문은 한 수준 깊이만 복사하는 얕은 구문입니다.
- 중첩된 객체를 업데이트하려면 업데이트하려는 위치에서 위쪽까지 복사본을 만들어야 합니다.
- 반복적인 코드 복사를 줄이려면 Immer를 사용하세요.
과제 1 / 3: 잘못된 상태 업데이트 수정하기
이 양식에는 몇 가지 버그가 있습니다. 점수를 높이는 버튼을 몇 번 클릭해 보세요. 점수가 올라가지 않는 것을 확인합니다. 그런 다음 이름을 수정하고 점수가 갑자기 변경 사항을 "따라잡는" 것을 확인합니다. 마지막으로 성을 편집하고 점수가 완전히 사라진 것을 확인합니다.
여러분의 임무는 이 모든 버그를 수정하는 것입니다. 버그를 수정하면서 각 버그가 발생한 이유를 설명하세요.
과제 2/3: 돌연변이 찾기 및 수정하기
정적 배경에 드래그 가능한 상자가 있습니다. 선택 입력을 사용하여 상자의 색상을 변경할 수 있습니다.
하지만 버그가 있습니다. 상자를 먼저 이동한 다음 색상을 변경하면 배경(움직여서는 안 되는!)이 상자 위치로 "점프"합니다. 하지만 이런 일이 일어나지 않아야 합니다. 배경의 위치 소품은 초기 위치로 설정되어 있으며, { x: 0, y: 0 }. 색상이 변경된 후 배경이 움직이는 이유는 무엇인가요?
버그를 찾아서 수정하세요.
과제 3/3: Immer로 객체 업데이트하기
이전 챌린지와 동일한 버그가 있는 예제입니다. 이번에는 Immer를 사용하여 돌연변이를 수정합니다. 편의를 위해 Immer는 이미 임포트되어 있으므로 이를 사용하려면 모양 상태 변수를 변경해야 합니다.
https://codesandbox.io/s/3juca-lwrk1m?file=/src/UpdatingObjectsInState/UpdateAnObjectWithImmer.js
발표요약)
1. state를 변경할때는 setter 함수를 쓴다. 객체도 예외없다. 리액트의 state는 불변으로 취급한다.
2. setState할때, 로컬 변수를 수정하고, 변경된 새로운 변수를 넣어줄수도 있고, let new = obj
3. spread 문법을 쓸수도있다. {...prev, score:1}
4. 객체의 depth가 깊어지면 immer를 쓰는것도 방법이다.