
상태는 객체를 포함한 모든 종류의 JavaScript 값을 보유할 수 있다. 하지만 리액트 상태에 있는 객체를 직접 변경해서는 안된다. 대신, 객체를 업데이트하려면 새 객체를 생성하거나 기존 객체의 본사본을 만든 다음 해당 복사본을 사용하도록 상태를 설정해야 한다.

모든 종류의 JavaScript 값을 상태에 저장할 수 있다.
const [x, setX] = useState(0);
지금까지 숫자, 문자열, 부울을 사용해 작업해왔다. 이러한 종류의 Javascript 값은 ‘불변’이다. 즉, 변경할 수 없거나 ‘읽기 전용’을 의미한다. 값을 대체하기 위해 다시 렌더링을 트리거할 수 있다.
setX(5);
x 상태는 0 에서 5 로 변경되었지만 숫자 0 자체는 변경되지 않았다. JavaScript에서는 숫자, 문자열, 부울과 같은 원시값을 변경할 수 없다.
이제 상태에 있는 객체를 고려해보아라.
const [position, setPosition] = useState({ x: 0, y: 0 });
기술적으로는 객체 자체의 내용을 변경하는 것이 가능하다. 이를 mutation 이라고 한다.
position.x = 5;
하지만 리액트 상태의 객체는 기술적으로 변경 가능하지만 숫자, 부울, 문자열처럼 변경 불가능한 것처럼 처리해야한다. 이를 변경하는 대신 항상 교체해야 한다.
즉, 상태에 넣은 모든 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;
}}
이 코드는 이전 렌더링에서 position에 할당된 객체를 수정한다. 그러나 상태 설정 함수를 사용하지 않으면 리액트는 객체가 변경되었는지 전혀 알 수 없다. 따라서 리액트는 이에 대한 응답으로 아무것도 하지 않는다. 이는 이미 식사를 마친 후에 주문을 하는 것과 같다. 상태 변경이 어떤 경우에는 작동할 수 있지만 권장하지 않는다. 렌더링에서 액세스할 수 있는 상태 값을 읽기 전용으로 처리해야한다.
이 경우 실제로 리렌더링을 실행하면 새 객체를 만들어 상태 설정 함수에 전달해라.
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
setPosition 을 사용하면 리액트에게 다음과 같이 지시한다.
position 을 바꿔라.미리보기 영역을 터치하거나 마우스오버할 때 빨간색 점이 포인터를 어떻게 따라가는지 확인해라.
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
Local mutation is fine
이와 같은 코드는 상태의 기존 객체를 수정하기 때문에 문제가 된다.
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
});
Mutation은 이미 상태에 있는 기존 객체를 변경할 때만 문제가 된다. 방금 생성한 객체를 변경하는 것은 아직 다른 코드가 참조하지 않기 때문에 괜찮다. 이를 변경한다고 해서 이에 의존하는 항목에 실수로 영향을 미치는 일은 없다. 이를 ‘local mutiation’이라고 한다. 렌더링하는 local mutation을 수행할 수 있다. 매우 편리하고 괜찮다.
이전 예에서 position 객체는 항상 현재 커서 위치에서 새로 생성된다. 그러나 생성 중인 새 객체의 일부로 기존 데이터를 포함하고 싶은 경우가 많다. 예를 들어 form에서 하나의 필드만 업데이트하고 다른 모든 필드에 대해서는 이전 값을 유지하고 싶을 수 있다.
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
});
모든 속성을 별도로 복사할 필요가 없도록 ... object spread를 사용할 수 있다.
setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});
이제 작동한다.
각 입력 필드에 대해 별도의 상태 변수를 선언하지 않은 방법에 주목해라. 큰 form의 경우 모든 데이터를 하나의 객체로 그룹화하는 것은 올바르게 업데이트하기만 하면 매우 편리하다.
import { useState } from 'react';
export default function Form() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: 'bhepworth@sculpture.com'
});
function handleFirstNameChange(e) {
setPerson({
...person,
firstName: e.target.value
});
}
function handleLastNameChange(e) {
setPerson({
...person,
lastName: e.target.value
});
}
function handleEmailChange(e) {
setPerson({
...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>
</>
);
}
... 스프레드 구문은 ‘얕은 복사’이다. 즉, 한수준 깊이의 항목만 복사한다. 이렇게 하면 속도가 빨라지지만 중첩된 속성을 업데이트하려면 해당 속성을 두 번 이상 사용해야 한다는 뜻이다.
Using a single event handler for multiple fields
또한 객체 정의 내에서 [ 과] 중괄호를 사용하여 동적 이름으로 속성을 지정할 수도 있다. 다음은 동일한 예이지만 세가지 다른 이벤트 핸들러 대신 단일 이벤트 핸들러를 사용한다.
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>
</>
);
}
Here, e.target.name refers to the name property given to the <input> DOM element.
여기서 e.target.name 은 <input> DOM 요소에 부여된 name 속을 의미한다.
다음과 같은 중첩된 객체구조를 고려해보아라.
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';
하지만 리액트에서는 상태를 불변으로 취급한다. 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
Objects are not really nested
이와 같은 객체는 코드에서 “중첩”된 것으로 나타난다.
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 를 mutate 하면 obj2.artwork.city 와 obj1.city 모두에 영향을 미친다. 이는 obj3.artwork , obj2.artwork 그리고 obj1 이 동일한 객체이기 때문이다. 객체를 “중첩된” 것으로 생각하면 이를 확인하기 어렵다. 대신, 이들은 속성을 통해 서로 “가리키는” 별도의 객체다.
상태가 깊게 중첩된 경우 flattening을 고려할 수 있다. 그러나 상태 구조를 변경하고 싶지 않다면 중첩 스프레드에 대한 지름길을 선호할 수도 있다. Immer은 편리하지만 변경 가능한 구문을 사용하여 작성하고 복사본을 생성 해주는 인기 있는 라이브러리이다. Immer을 사용하면 작성하는 코드는 “규칙을 어기고” 객체를 변경하는 것처럼 보인다
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
하지만 일반 mutation과 달리 과거 상태를 덮어쓰지 않는다.
💡 DEEP DIVEHow does Immer work?
Immer가 제공하는 draft는 사용자가 수행하는 작업을 “기록”하는 Proxy라고 하는 특수한 유형의 객체이다. 그렇기 때문에 원하는 만큼 자유롭게 변경할 수 있다. 내부족으로 Immer은 draft 의 어느 부분이 변경되었는지 파악하고 편집 내용이 포함된 완전히 새로운 객체를 생성한다.
Immer 사용하기
npm install use-immer 를 싱행하여 Immer를 중속 항목으로 추가해라.import { useState } from 'react' 를 import { useImmer } from 'use-immer' 로 바꿔라.다음은 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는 특히 상태에 중첩이 있고 객체를 복사하면 반복적인 코드가 발생하는 경우 업데이트 핸들러를 간결하게 유지하는 좋은 방법이다.
Why is mutating state not recommended in React?
몇가지 이유가 있다.
console.log 를 사용하고 상태를 변경하지 않으면 최근 상태 변경으로 인해 과거 로그가 손상되지 않는다. 따라서 렌더링 간에 상태가 어떻게 변경되었는지 명확하게 확인할 수 있다.prevObj === obj 인 경우 내부에는 아무것도 변경되지 않았음을 확신할 수 있다실제로 리액트에서 상태를 변경하여 “탈출”할 수 있는 경우가 많지만, 이 접근 방식을 염두에 두고 개발된 새로운 리액트 기능을 사용할 수 있도록 그렇게 하지 않는 것이 좋다. 미래의 기여자들과 아마도 미래의 당신 자신도 당신에게 감사할 것이다.
{...obj, something: 'newValue'} 객체 스프레드 구문을 사용하여 객체의 복사본을 만들 수 있다.