리액트는 상태(state)를 설정할 때는 새로운 값을 전달하지 않으면 컴포넌트를 다시 렌더링하지 않습니다.
리액트에서는 이전 상태와 새로운 상태를 비교하기 위해 ===
(일치연산자)와 비슷한 것을 사용합니다. 물론 문서에서는 ===
대신 Object.is()
를 사용한다고 설명하고 있지만, 이 둘은 기본적으로 동일합니다.
둘 다 얕은 동등성 검사를 수행하며, 이것이 객체와 배열을 단순히 변경해도 상태 변경을 알리지 않는 이유입니다.
변경사항을 반영하여 새로운 참조를 만들지 않으면 리액트가 상태 변경을 인지할 수 없습니다. 따라서 리액트는 얕은 동등성 검사를 충족시키기 위해 변경된 객체와 배열의 완전히 새로운 복사본이 필요합니다.
JavaScript의 객체와 배열은 변경될 수 있습니다.
const user = {}
user.name = 'brad' // mutation (changed the user object)
const
로 선언된 변수를 변경할 수 있을까요?
답은 가능하다입니다.
const
를 사용하는 것은 "새로운 값"으로 재할당될 수 없음을 의미하지 "변경"이 불가능한 것은 아닙니다.
예제를 다시 살펴봅시다.
const user = {}
user.name = 'brad' // 허용 o, 객체 변경 mutate
user = 9 // 허용x, 상수를 새로운 값으로 재할당하는 것은 허용되지 않는다.
user = { name: 'brad' } // 허용x, 상수를 새로운 값으로 재할당하는 것은 허용되지 않는다.
위의 예제가 헷갈릴 수는 있지만, mutate
와 not mutate
를 혼동해서는 안됩니다.
배열의 객체를 제거하여 배열을 변이(mutate
)시키는 또 다른 예제를 살펴보겠습니다.
const users = [
{ id: 1, name: 'michael' },
{ id: 2, name: 'brad' }, // <-- 제거 예정
{ id: 3, name: 'ryan' },
]
const index = users.findIndex((u) => u.id === 2);
// 변이: splice 메서드를 사용하여 배열에 추가하거나 제거 가능
users.splice(index, 1)
이러한 종류의 작업을 항상 수행하며 일반적인 개념에는 아무런 문제가 없습니다. 그렇다면 왜 사람들은 이러한 mutate
가 나쁘다고 주장하는 걸까요?
시간의 흐름에 따라 변경되는 state에 대해 변이(mutate)를 수행하는 것은 버그를 유발시킬 수 있기 때문입니다.
불변성을 유지하고자 할 때, 이는 데이터 혹은 state
가 절대 변경되지 않는다는 것을 의미하는 것이 아닙니다. (사실 그 반대입니다.) 모든 Application의 데이터는 시간이 지남에 따라 변경되어야 하지만, 불변성을 유지하는 것은 그 변경을 어떻게 다룰지를 결정하는 기술일 뿐입니다.
배열을 변경하는 대신에 변경 사항이 있는 새로운 배열로 원래 배열을 교체하는 것이 어떨까요?
다음과 같은 순서일 겁니다.
1. 원본을 복사합니다.
2. 복사본에 변경 사항을 적용합니다.
3. 원본을 복사본으로 교체합니다.
이것이 바로 불변성입니다.
일반적으로 변이성(mutability) 또는 불변성(immutability)을 사용할지를 결정할 수 있지만, 리액트는 상태(state)의 불변성을 유지해야 합니다.
다시 말하자만, 우리는 상태를 변경하려면 변이(mutate)를 사용하는 대신에 복사본을 만들고 이전 상태를 새로운 사본으로 교체해야 합니다.
아래 코드는 불변성
을 유지한 상태에서의 위의 코드를 리펙토링한 코드입니다.
const users = [
{ id: 1, name: 'michael' },
{ id: 2, name: 'brad' }, // <-- 제거 예정
{ id: 3, name: 'ryan' },
]
const index = users.findIndex((u) => u.id === 2);
// 1. 원본을 복사합니다.
// 2. 복사본에 변경 사항을 적용합니다.
// 3. 원본을 복사본으로 교체합니다.
const newArray = [...users.slice(0, index), ...users.slice(index + 1)]
const newArray = []
를 수행하게 되면, 새로운 배열을 만드는 것입니다. 그런 다음에 새로운 배열을 기존 배열의 일부로 채웁니다.
slice
를 사용하면 배열의 복사본을 만들기 때문에 우리는 여기에 원하는 인덱스까지의 모든 부분을 복사하고 있음을 알 수 있습니다. 그런 다음 인덱스 이후의 모든 부분을 복사합니다.
일반적으로 이러한 코드는 변이(mutate)
보다 복잡합니다.
이 개념을 더 확고히 하기 위해 또 다른 예제를 살펴봅시다.
const person = { name: 'brad', occupation: 'web' }
changeOccupation(person, 'web developer');
function changeOccupation(person, occupation) {
// mutation 방법입니다.
person.occupation = occupation
return person
}
// Immutable한 방법입니다. - 옵션 1
function changeOccupation(person, occupation) {
return Object.assign({}, person, { occupation: occupation })
}
// Immutable한 방법입니다. - 옵션 2
function changeOccupation(person, occupation) {
return { ...person, occupation: occupation }
}
Object.assign()
의 경우, 임의의 개수의 객체를 가져와서 오른쪽에서 왼쪽으로 병합한 후 가장 왼쪽의 객체에 반환 후 새로운 객체를 반환합니다.Object.assign()
을 사용하는 것은 좀 더 옛스러운 방법입니다.Object.assign()
보다 더 현대적인 방법으로 간주되지만, 실제로는 둘 다 같은 일을 수행합니다.자바스크립트에서 숫자, 문자열과 같은 원시 타입(primitives)은 기본적으로 항상 불변성을 가집니다.
다음 예제는 위에서 이미 언급한 내용과 유사하게 변이(mutate)
로 보일 수 있지만, 실제로는 그렇지 않습니다.
let x = 1
x = 2
MDN 문서에서는 다음과 같이 언급됩니다.
원시 타입(primitives)는 변경할 수 없습니다.
변수는 새로운 값으로 재할당될 수 있지만, 기존 값은 객체, 배열 및 함수가 변경될 수 있는 방식으로 변경될 수 없습니다.
많은 개발자들은 원시타입(primitives)에 대한 가변성(mutable) 대 불변성(immutable)을 고려하지 않습니다. 결국에는 변이(mutate)처럼 느껴지기 때문입니다. 일반적으로 "불변성"에 대해 이야기할 때, 우리는 객체와 배열에 대해 이야기하는 것입니다.
이 섹션에서는 useState
와 다시 렌더링이 함께 작동하는 방법에 대해 약간 알고 있다고 가정합니다.
React는 상태를 설정할 때 새로운 값을 원합니다.
이것이 일치 연산자를 통해 다시 렌더링을 수행할지 렌더링을 건너뛸지 여부를 알게되는 방법입니다.
push()
를 사용하여 상태(state)에서 배열을 변이(mutate)를 시도해봅시다.
function App() {
const [users, setUsers] = useState([
{ id: 1, name: 'michael' },
{ id: 2, name: 'brad' },
{ id: 3, name: 'ryan' },
])
function addUser(newUser) {
// mutation
users.push(newUser)
}
return (
<div>
<AddUserForm onSubmit={addUser} />
<ShowUsers users={users} />
</div>
)
}
일반적으로 배열에 값을 추가하려고 할 때 .push()
를 떠올립니다. 문법적으로 .push()
를 사용하는 것에는 문제가 없지만, 이것은 변이(mutation)
이며 이것은 "상태를 변이하는" 규칙을 어기는 것입니다. 그렇다면 리액트에서는 오류를 알려줄까요?
그렇지 않고 단지 리렌더링이 되지 않습니다.
이번에는 setState
를 통해 리렌더링을 시도해봅시다.
function addUser(newUser) {
users.push(newUser)
setUsers(users)
}
원하는 데로 작동되지 않습니다. 😩
리액트는 새로운 값을 필요로 하기 때문에 사실상 불변성을 적용하도록 강요합니다.
예를 들어, 3개의 항목을 가진 배열이 있고 4번째 항목을 추가하면 배열 참조는 여전히 동일합니다. 그 후에 동일한 배열 참조로 setState
를 호출하면, 리액트는 깊이있는 항목을 탐색하는 것이 아니라 얕은 동등성 검사를 수행합니다. 리액트는 단순히 다음과 같은 조건을 확인합니다:
oldArray !== newArray
위의 코드에서 배열은 동일한 참조를 가지므로 리액트는 다시 렌더링하지 않습니다. 객체와 배열(그리고 함수)을 서로 비교할 때는 항상 식별 참조(identity reference)로 비교되기 때문에 다음과 같은 단계를 거쳐야 합니다:
다시 말해, 불변성을 유지해야 합니다.
function addUser(newUser) {
// 리렌더링이 성공적으로 작동합니다.
setUsers([...users, newUser])
}
function addUser(newUser) {
// 리렌더링이 성공적으로 작동합니다.
setUsers(users.concat(newUser))
}
concat
메서드는 push
와 비슷해 보이지만 실제로는 배열의 사본을 만들고 새 값을 추가한 사본을 반환할 수 있습니다. MDN에 따르면:
concat
메서드는 기존 배열을 수정하는 것이 아닌 새로운 배열을 리턴한다.
이것이 React 개발자들이 객체의 상태를 변경하기 위해 이러한 유형의 작업을 하는 이유입니다.
setMyObject({ ...myObject, newStuff })
setMyObject(Object.assign({}, myObject, newStuff))
간단한 카운터를 만들고 싶다고 가정해봅시다.
이 코드는 제대로 작동하지 않습니다. 왜냐하면 setCount
를 호출하여 다시 렌더링을 시키지 않았기 때문입니다.
function Counter() {
let [count, setCount] = useState(0)
function add() {
count++
}
return <button onClick={add}>{count}</button>
}
만약 리렌더링을 시키고 싶다면 다음과 같이 해야합니다.
function Counter() {
let [count, setCount] = useState(0)
function add() {
count++
setCount(count)
}
return <button onClick={add}>{count}</button>
}
위 코드의 리렌더링은 잘 수행될 것입니다.
그러나
count++
를 사용해서는 안됩니다. 왜냐하면 상태를 직접 변경해서는 안 되기 때문입니다
count++
이 잘 동작하기 때문에 일부 개발자들은 변이가 허용된다고 오해할 수 있습니다.
올바르게 하려면 직접 변경하는 대신에 setCount(count + 1)
을 사용해야 합니다. 이렇게 하면 우리가 리액트에게 다음 상태 값을 제공하되 직접 변경하지 않는다는 것이 더 명확해집니다.
다시 말해, 상태를 직접 변경하는 대신에 새 값을 사용하여 새 상태를 얻어서 다시 렌더링을 생성해야 합니다.
Props
는 읽기 전용(readonly)이고 불변(immutable)해야합니다. 컴포넌트의 props
는 아마도 상위 컴포넌트의 상태일 것입니다.
Props
를 변경하면 상태를 변경하는 것과 마찬가지로 다시 렌더링이 발생하지 않습니다. props
를 변형하면 원하는 결과를 얻는 것처럼 보일 수도 있지만, 이는 버그의 원인이 될 수 있습니다. 이를 하지 마세요.
보다 넓은 의미에서, React에서 상태(state)는 시간이 지남에 따라 변경되는 모든 값입니다. useState
와 useReducer
은 상태를 만드는 한 가지 방법일 뿐이지만, 다른 방법들도 있습니다.
값이 시간이 지남에 따라 변경되지만 변경할 때마다 다시 렌더링을 원하지 않는 경우가 있습니다. 이 경우 mutable ref
를 사용하고 싶을 수 있습니다.
const myCount = useRef(0) // returns { current: 0 }
function onClick() {
myCount.current++;
}
ref
가 "DOM에 액세스하기 위해 사용하는 것"이라는 개념으로만 이해하면 약간 헷갈릴 수 있습니다.
이것을 mutable ref
라고 합니다. 왜냐하면 필요할 때마다 .current
속성을 문자 그대로 변이시키기 때문입니다.
변이를 수행하면 다시 렌더링이 발생하지 않지만, 최종적으로 다시 렌더링이 발생하면 이 useRef
는 제공한 최신 값을 반환할 것입니다.
따라서 이것은 상태와 마찬가지로 렌더링 사이에 시간이 지남에 따라 변경될 수 있는 변수입니다.
가끔은 깊은 배열이나 객체를 다루는 것이 불변성을 유지하는 것이 복잡할 수 있습니다.
여기서 우리는 작은 트릭을 사용할 수 있습니다.
우리는 원본을 깊게 복사한 후 복사본을 가지고 기술적으로 복사본을 "변이"할 수 있고, 그런 다음 원래 값에 그 복사본을 대체할 수 있습니다. 우리는 복사본을 변형할 수 있지만, 최종적으로 전략은 여전히 원본을 변형하지 않기 때문에 동일한 불변(immutable) 느낌을 유지합니다.
const person = { name: 'brad', occupation: 'web' };
changeOccupation(person, 'web developer');
function changeOccupation(person, occupation) {
const personCopy = JSON.parse(JSON.stringify(person));
personCopy.occupation = occupation;
return personCopy;
}
이것이 JSON 직렬화 트릭
입니다.
객체를 문자열로 직렬화한 다음 다시 객체로 역직렬화합니다. 결과적으로 객체의 깊은 복제본이 생성되며, 따라서 personCopy
는 person
과 다른 것을 참조합니다.
changeOccupation()
함수는 불변성 전략을 사용한다고 말할 수 있습니다. 왜냐하면 이 함수는 person
을 가져와 변경된 새로운 person 참조
를 반환하기 때문입니다.
배열의 경우에는 myarray.slice()
를 사용하여 인수 없이 호출하여 복사본을 만들 수도 있습니다(완전히 새로운 참조를 생성합니다).
이러한 깊은 복제 전략 중 하나의 문제는 변경할 필요가 없는 많은 항목을 깊게 복제하는 오버헤드입니다. 그러나 데이터를 잘 알고 있고 그 크기가 작고 오버헤드가 미미한 것을 알고 있다면, 깊은 복제를 수행하는 것은 성능 측면에서 큰 문제가 되지 않을 수 있으며 코드를 훨씬 더 읽고 쓰기 쉽게 만들 수 있습니다.
JSON 직렬화 트릭
은 인기 있었지만 javascript에서 이를 공식적으로 수행해주는 structuredClone() 메서드를 사용하도록 합시다.