상태는 자바스크립트 값이라면 객체를 포함한 어떤 값이든 저장할 수 있어요. 그러나 리액트 상태 안에서 직접적으로 객체를 수정하면 안돼요. 대신, 새로운 객체를 생성하거나 기존 객체의 복사본을 만들고 해당 복사본을 사용하여 상태를 설정해야해요.
이 페이지에서는
- 리액트 상태 안의 객체를 어떻게 올바르게 업데이트 하는지
- 변이 없이 중첩 객체를 어떻게 업데이트 하는지
- 불변성이 무엇이고 어떻게 불변성을 유지하는지
- 어떻게 Immer를 사용하여 반복되어 나오는 구문을 줄인 채 객체를 복사하는지
를 알아볼 거예요.
What's a mutation? | 변이란 무엇인가요?
상태는 모든 자바스크립트 변수를 저장할 수 있어요.
const [x, setX] = useState(0);
따라서 숫자, 문자열, 그리고 불리안 타입이 모두 들어갈 수 있어요. 이러한 자바스크립트 값은 "불변"하고 이는 "바꿀 수 없다" 내지는 "읽기 전용이다"라는 의미에요. 값을 대체하여 리렌더링을 발생시킬 수 있어요.
setX(5);
상태 x
는 0
에서 5
로 바뀌었지만 숫자 0
자체는 수정되지 않아요. 자바스크립트에서 숫자, 문자열, 불리안과 같은 내장된 원시 값들은 바꿀 수 없어요.
이제는 상태 안에 있는 객체를 생각해볼게요.
const [position, setPosition] = useState({ x: 0, y: 0 });
기술적으로는 객체 자체의 콘텐츠를 바꿀 수 있어요. 이를 뮤테이션, 즉 변이라고 해요.
position.x = 5;
그러나 리액트 상태의 객체가 기술적으로는 변이가 가능할지라도 마치 그것들이 불변한 것처럼 다루어야해요. 숫자, 문자열, 불리안과 같이요. 객체 상태를 변이하는 대신에 항상 그들을 대체하세요.
Treat state as read-only | 상태를 읽기 전용으로 취급하기
이는 즉슨, 상태에 넣은 어떤 자바스크립트 객체도 읽기 전용으로 다루어야한다는 말이에요.
이 예시는 현재 포인터의 위치를 표시하는 객체 상태값을 갖고 있어요. 붉은 점은 미리보기 영역에서 커서를 만지거나 이동하면 변해야해요. 하지만 이 점은 초기 상태에 머물러요.
문제는 아래의 짧은 코드에서 발생해요.
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
이 코드는 이전 렌더링에서 position
에 할당된 객체를 수정해요. 하지만 상태의 세팅 함수를 사용하지 않으면 리액트는 객체가 바뀐다고 생각하지 않아요. 그래서 리액트는 아무런 반응을 하지 않아요. 이는 이미 여러분이 식사를 한 후에 주문을 바꾸는 것과 같아요. 상태 변이가 어떤 상황에서는 잘 동작하지만 우리는 추천하지 않아요. 상태 변수를 렌더링 하는 동안 읽기 전용으로 접근할 수 있는 것처럼 다루세요.
실제로 이 경우에서 리렌더링을 발생시키려면 새로운 객체를 만들고 상태의 세팅 함수에 전달하세요.
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
setPosition
함수를 사용하면 리액트에게 다음 내용을 알려주는 거예요.
position
을 새로운 객체로 대체하기- 그리고 이 컴포넌트를 다시 렌더링하기
미리보기 영역을 터치하거나 호버할 때 빨간 점이 이제 여러분의 포인터를 어떻게 따라다니는지를 확인하세요.
지역 변이는 괜찮아요.더보기아래와 같은 코드는 상태 안에 있는 기존 객체를 수정하기 떄문에 문제가 발생해요.
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 });
변이는 상태 안에 이미 존재하는 기존 객체를 바꿀 때만 문제가 발생해요. 이미 만든 객체를 변이하는 것은 다른 코드를 아직 참조하지 않기 때문에 문제가 되지 않아요. 이 객체를 바꾸는 것은 이 객체에 의존하는 것들에 우연히라도 영향을 주지 않을 거예요. 이를 "지역 변이"라고 해요. 지역 변이는 렌더링 하는 동안에도 할 수 있어요. 매우 편리하고 문제를 일으키지도 않는 방법이에요!
Copying objects with the spread syntax | 스프레드 구문을 사용하여 객체 복사하기
이전의 예시에서 position
객체는 현재 커서의 위치로 새롭게 생성되었어요. 하지만 종종 기존 데이터를 새롭게 생성한 객체에 포함시키고 싶을 수도 있어요. 폼에서 하나의 필드만 업데이트 하고 싶지만 이전의 값을 모든 다른 필드에서 유지하고 싶은 경우가 이에 해당돼요.
아래의 입력 필드는 onChange
핸들러가 상태를 변이시키기 때문에 작동하지 않아요.
예를 들어, 아래의 코드는 이전 렌더링에서 상태를 변이시켜요.
person.firstName = e.target.value;
여러분이 원했던 것처럼 동작하게 만드는 믿을만한 방법은 새 객체를 만들고 setPerson
에 이를 전달하는 거예요. 하지만 아래를 보면 하나의 필드만 변경되었기 떄문에 기존 데이터도 복사하고 있어요.
setPerson({
firstName: e.target.value, // 입력창에서 받은 새로운 이름
lastName: person.lastName,
email: person.email
});
객체 스프레드인 ...
문법을 사용하면 모든 속성을 하나하나 복사할 필요가 없어요.
setPerson({
...person, // 기존의 필드 복사하기
firstName: e.target.value // 하지만 이 속성은 오버라이딩 하세요.
});
이제 새로운 폼이 잘 동작해요!
각각의 입력창에 대한 상태 변수를 하나하나 선언하지 않는다는 점에 주목하세요. 큰 폼에서 모든 데이터를 하나의 객체로 묶는 것은 올바르게 업데이트만 한다면 정말 편리해요!
...
스프레드 구문은 얕은 복사라는 것을 기억하세요. 오직 한 단계만 복사해요. 이렇게하면 빠르게 복사될 뿐만 아니라 중첩된 속성을 업데이트 할 때 여러 번 사용할 수 있다는 것도 의미해요.
단일 이벤트 핸들러를 여러 필드에서 사용하기더보기객체 정의에서 동적 이름으로 속성을 지정하기 위해
[
와]
괄호를 사용할 수 있어요. 아래는 같은 예시지만 세 개의 각기 다른 이벤트 핸들러가 아닌 단일 이벤트 핸들러만 가지고 있어요.
여기서
e.target.name
은<input>
DOM 요소에서 부여한name
속성을 참조해요.
Updating a 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
를 업데이트 하고 싶다면 변이를 사용하면 깔끔하게 해결할 수 있어요.
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, // 다른 필드를 복사하세요
artwork: { // 단 artwork는 변경하세요
...person.artwork, // 같은 값을 넣고
city: 'New Delhi' // New Delhi를 추가하세요
}
});
이는 조금 더 구구절절하지만 여러 경우에서도 잘 작동해요.
객체는 실제로 중첩되지 않아요더보기이 객체는 코드에서 "중첩되어" 보일 거예요.
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.artkwork
,obj2.artwork
그리고obj1
은 모두 동일한 객체이기 때문이에요. 만약 객체가 "중첩" 되었다고 생각한다면 알기 어려울 거예요. 대신 이 객체들은 서로의 속성을 "가리키고 있는" 각기 다른 객체들이에요.
Write concise update logic with Immer | Immer를 사용하여 업데이트 로직을 간결하게 작성하기
만약 상태가 깊이 중첩되어 있다면 이를 평평하게 만들고 싶을 수도 있어요. 하지만 만약 상태 구조를 바꾸고 싶지 않다면 중첩 스프레드를 사용한 단축구문을 선호할 거예요. Immer는 편리하지만 변형 구문을 사용하여 작성하고 복사본을 생성할 수 있는 인기있는 라이브러리에요. Immer를 사용하면 코드는 마치 "규칙을 어긴 것"처럼 보이고 객체를 변이할 거예요.
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
하지만 일반적인 변이와는 달리 이전 상태를 덮어쓰진 않아요.
Immer는 어떻게 작동하나요?
Immer를 사용해보세요.
- 의존성에 Immer를 추가하려면
npm install use-immer
를 넣으세요. import { useState } from 'react'
를import { useImmer } from use-immer
로 대체하세요.
아래는 Immer로 변환하는 예시에요.
이벤트 핸들러가 얼마나 더 간결해졌는지를 살펴보세요. useState
와 useImmer
를 단일 컴포넌트에서 원하는 만큼 섞어서 사용할 수 있어요. Immer는 특히 상태가 중첩되어있고 객체를 복사하면 반복적인 코드가 작성될 때 업데이트 핸들러를 간결하게 유지하는 좋은 방법이에요.
왜 상태 변이가 리액트에서 추천되지 않나요?더보기이유는 몇가지가 있어요.
- 디버깅: 만약
console.log
를 사용하고 있고 상태를 변이하지 않는다면 이전 로그는 더 최신의 상태 변화로 덮이지 않아요. 따라서 여러분은 상태가 렌더링 사이에 어떻게 변하는지 명확히 볼 수 있어요.- 최적화: 흔한 리액트 최적화 전략은 만약 이전 props나 상태가 그 다음과 같으면 동작을 건너뛰는 것에 의존해요. 만약 상태가 절대 변이되지 않는다면 변화가 있는지를 확인하는 것은 굉장히 빨라요. 만약
prevObj === obj
라면, 내부에서 아무것도 변하지 않았다는 것을 확신할 수 있어요.- 새로운 특성: 우리가 만들고 있는 새로운 리액트의 특성은 상태가 스냅샷처럼 다루어진다는 점에 의존하고 있어요. 만약 상태의 이전 버전이 변이된다면 새로운 특성을 사용하지 못할 거예요.
- 필수가 변화한다: 변화 기록을 보여주거나 폼을 이전 값으로 되돌리게 만드는 Undo/Redo의 구현과 같은 어떤 어플리케이션 특성은 아무것도 변이하지 않을 때 동작하기 편해요. 만약 변이가 있는 접근을 사용했다면 이와 같은 특성은 나중에 추가하기 어려워요.
- 쉬운 구현: 리액트는 변이에 의존하지 않기 때문에 객체로 새로운 일을 할 필요가 없어요. 속성을 하이재킹할 필요가 없고 항상 프록시로 감쌀 필요도 없고, 다른 "반응형" 솔루션들이 초기화할 때 하는 작업을 할 필요도 없어요. 이 또한 리액트가 성능이나 정확성에 문제가 되지 않게 크기 상관 없이 아무 객체를 상태에 넣을 수 있는 이유에요.
실제로는 리액트에서 상태 변이를 사용할 수는 있지만 사용하지 마세요. 그러면 이 방법으로 개발된 원하는 리액트 특성을 사용할 수도 있어요. 미래의 컨트리뷰터와 아마도 여러분 자신은 이에 감사할 거예요!
Recap | 요약
- 리액트에서 모든 상태는 불변해요.
- 상태에 객체를 저장하면 이들을 변이하는 것은 렌더링을 유발시키고 이전 렌더링의 "스냅샷"에서 상태를 변경해요.
- 객체를 변이하는 대신 새로운 객체를 생성하고 그 안에 상태를 세팅하여 리렌더링을 발생시키세요.
- 객체의 복사본을 만들 때
{...obj, something: 'newValue'}
객체 스프레드 문법을 사용할 수 있어요. - 스프레드 구문은 얕은 복사에요. 한 단계만 복사해요.
- 중첩된 객체를 업데이트 하려면 업데이트하는 위치부터 모든 방향으로 복사본을 만들어야해요.
- 반복되는 복사 코드는 줄이려면 Immer를 사용하세요.
Challenge | 도전 과제
1. 잘못된 상태 업데이트 고치기
이 폼은 버그가 있어요. 점수를 몇 번 증가시키는 버튼을 클릭하세요. 점수가 올라가지 않는 것을 볼 수 있어요. 그리고 나서 이름을 수정하면 점수가 갑자기 이 변화를 "잡아낸다"는 것을 알 수 있어요. 마지막으로 성을 변경하면 점수가 완벽하게 사라지는 것을 볼 수 있어요.
여러분의 과제는 이 모든 버그를 고치는 거예요. 이 버그들을 고치면 왜 각각의 일이 일어나는지 설명하세요.
아래는 버그가 모두 해결된 버전이에요.
handlePlusClick
의 문제는 player
객체를 변이한 거예요. 결론적으로 리액트는 리렌더링 해야한다는 것을 몰랐고 화면에서 점수를 업데이트하지 않았어요. 이것이 이름을 수정했을 때 상태가 업데이트 되고 화면에서 점수도 업데이트된 리렌더링을 발생시킨 이유에요.
handleLastNameChange
의 문제는 기존의 ...player
필드를 새로운 객체 안으로 복사하지 않았던 거예요. 이것이 바로 점수가 이름을 변경한 후에 사라진 이유에요.
2. 변이 찾고 고치기
정적인 배경에서 드래그가 가능한 박스가 있어요. 셀렉트 입력을 사용하여 박스의 색상을 변경할 수 있어요.
그러나 버그가 있어요. 만약 박스를 먼저 움직인 후 색상을 변경한다면 (움직이지 않아야하는) 배경은 박스의 위치로 이동할 거예요. 그러나 이는 일어나면 안돼요. Background
의 position
prop은 initialPosition
로 설정되고 이것이 바로 { x: 0, y: 0 }
에요. 색상이 바뀐 후에 왜 배경이 이동할까요?
버그를 찾고 고쳐보세요.
만약 예상치 못하게 무언가 변했다면 그것은 변이에요. App.js에서 변이를 찾고 그것을 고치세요.
handleMove
안에서 일어난 변이에서 문제가 발생했어요. shape.position
가 변이되었지만 initialPosition
이 가리키는 것과 동일한 객체예요. 이것이 모양과 배경이 모두 이동하는 이유에요. (이는 변이이기 때문에 연관되지 않은 업데이트, 즉 색상 변화가 리렌더링을 발생시키기 전까지는 화면에 변화가 반영되지 않아요.)
고치는 방법은 handleMove
에서 변이를 없애고 shape를 복사하는데 스프레드 구문을 사용하는 것이에요. +=
는 변이이기 때문에 일반적인 +
구문을 사용하여 이를 덮어써야해요.
3. Immer로 객체 업데이트하기
이 예시는 앞의 도전 과제와 동일한 버그가 있는 예시에요. 이번에는 Immer를 사용하여 변이를 고치세요. 여러분의 편의를 위해서 useImmer
는 이미 불러왔기 때문에 이를 사용하여 상태 변수 shape
를 바꾸세요.
Immer를 사용하여 재작성한 정답 코드에요. 이벤트 핸들러가 변이 방식으로 작성되었지만 버그는 발생하지 않았다는 것을 기억하세요. 왜냐하면 내부적으로 Immer는 절대 기존 객체를 변이하지 않기 때문이에요.
'리액트 공식문서 | React Docs > Learn > Learn React' 카테고리의 다른 글
[Managing State] Managing State Overview | 상태 관리하기 개요 (0) | 2024.02.22 |
---|---|
[Adding Interactivity] Updating Arrays in State | 상태에서 배열 업데이트하기 (0) | 2024.02.21 |
[Adding Interactivity] Queueing a Series of State Updates | 일련의 상태 업데이트를 대기열에 넣기 (0) | 2024.02.19 |
[Adding Interactivity] State as a Snapshot | 상태는 스냅샷이다 (1) | 2024.02.19 |
[Adding Interactivity] Render and Commit | 렌더링과 커밋 (2) | 2024.02.18 |