팀원들과 Zustand의 불필요한 렌더링을 줄일 수 있는 올바른 사용방법에 대해서 논의하다가 꽤 깊어지게 되었는데, 이때 새롭게 알게된 것들을 정리해보려 합니다.
위 글을 보면서 불필요한 리렌더링을 줄이기 위해서는 atomic selectors를 사용하자는 의견이 나왔습니다. 하지만 하나의 Component에서 하나의 Store의 여러 State를 사용한다면 코드가 길어질 수 있기 때문에 이 경우 shllow
함수를 넘겨주기로 하였습니다.
import shallow from 'zustand/shallow'
// ⬇️ much better, because optimized
const { bears, fish } = useBearStore(
(state) => ({ bears: state.bears, fish: state.fish }),
shallow
)
위 글에서 shallow
함수를 넘겨주면 "선택기에서 객체나 배열을 반환하려면 얕은 비교를 사용하도록 비교 함수를 조정할 수 있습니다."라고 설명하고 있습니다.
여기서 "React는 기본적으로 상태 변경을 주소값을 비교하여 Component를 리렌더링하는데 이것을 얕은 비교라고 하지 않나? 어째서 옵션의 이름이 shallow
일까?" 라는 의문이 생겨 팀원들과 함께 알아보았습니다.
이것에 대해서 정리할 내용들은 아래와 같습니다.
React의 얕은 비교
Zustand의 Shallow, useShallow
주니어들끼리 머리를 맞대고 논의한 내용이기 때문에 틀린 내용이 있을 수 있습니다. 과감하게 지적해주시면 감사하겠습니다.
React에서 얕은 비교가 제가 정확하게 알고 있는 것이 맞는지 확인하기 위해서 찾아보던 중 위 글을 읽게 되었습니다. 위 글에서 설명하는 shallowEqual
함수에 대한 자세한 설명은 아래 글을 통해 쉽게 이해할 수 있습니다.
How Does Shallow Comparison Work In React?
/**
* Performs equality by iterating through keys on an object and returning false
* when any key has values which are not strictly equal between the arguments.
* Returns true when the values of all keys are strictly equal.
*/
function shallowEqual(objA: mixed, objB: mixed): boolean {
if (is(objA, objB)) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
for (let i = 0; i < keysA.length; i++) {
const currentKey = keysA[i];
if (
!hasOwnProperty.call(objB, currentKey) ||
// $FlowFixMe[incompatible-use] lost refinement of `objB`
!is(objA[currentKey], objB[currentKey])
) {
return false;
}
}
return true;
}
export default shallowEqual;
React github에 있는 shallowEqual
함수입니다.
실제 React의 shallowEqual
함수가 Object.is
를 사용해 객체의 주소를 비교하고 이후 객체 내부의 Property까지 비교를 합니다. 하지만 깊은 복사와는 다르게 재귀적으로 모든 깊이를 비교하지는 않습니다. 따라서 얕은 비교는 맞지만 객체의 경우 Property까지 비교하여 더 엄격하게 비교하는 것으로 보입니다.
Zustand github에서 shallow
함수를 찾을 수 있는데, 리액트의 shallowEqual
함수와 굉장히 유사합니다.
export function shallow<T>(objA: T, objB: T) {
if (Object.is(objA, objB)) {
return true
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false
}
if (objA instanceof Map && objB instanceof Map) {
if (objA.size !== objB.size) return false
for (const [key, value] of objA) {
if (!Object.is(value, objB.get(key))) {
return false
}
}
return true
}
if (objA instanceof Set && objB instanceof Set) {
if (objA.size !== objB.size) return false
for (const value of objA) {
if (!objB.has(value)) {
return false
}
}
return true
}
const keysA = Object.keys(objA)
if (keysA.length !== Object.keys(objB).length) {
return false
}
for (const keyA of keysA) {
if (
!Object.prototype.hasOwnProperty.call(objB, keyA as string) ||
!Object.is(objA[keyA as keyof T], objB[keyA as keyof T])
) {
return false
}
}
return true
}
shallowEqual
함수와 동일하게 Object.is
를 사용해 참조를 비교한 후 객체의 Property까지 비교하고 있습니다.
하지만 공식문서에서는 shallow
보다는 useShallow
훅을 사용하는 것을 권장하고 있었습니다.
Prevent rerenders with useShallow
해당 글에서는 Selector에서 반환하는 값이 변경되면 리렌더링이 발생하고 이때 변경을 감지하는데 참조 동일성을 판단하는 Object.is
를 사용한다고 합니다. Object.is
를 사용하면 주소값만 비교하고 끝나지만, useShallow
를 사용한다면 이를 방지하여 불필요한 리렌더링을 줄일 수 있다고 합니다.
아무래도 shallow
함수와 유사한 방법으로 객체안의 Property를 비교하여 값이 변경됐는지 확인하고 리렌더링하는 것으로 보입니다.
export default function App() {
const { bear, nuts } = useZustandStore(
useShallow((state) => ({
bear: state.bear,
nuts: state.nuts,
}))
);
}
따라서 위와 같이 useShallow
를 사용해 Selector함수를 전달한다면 반환된 객체 외의 다른 값이 변경되어도 Property가 같다고 판단하여 리렌더링이 발생하지 않는 것 같습니다.
React에서 사용되는 shallowEqual
함수의 얕은 비교는 객체를 비교할때 Object.is
를 통해 참조와 내부의 Property까지 비교합니다.
Zustand의 shallow
함수는 React의 shallowEqual
함수와 동일하게 객체 내부의 Property까지 비교합니다.
Store에 shallow
함수를 전달하지 않은 상태에서 Selector함수를 사용해 반환한 객체들은 Object.is
를 사용해 비교됩니다. 따라서 참조가 달라질때마다 매번 불필요한 리렌더링이 발생합니다.
3번을 방지하기 위해서 useShallow
의 사용을 권장하고 있습니다.
zustand의 shallow는 아무래도 React의 shallowEqual함수와 동일한 기능을 하기에 shallow라는 이름을 사용하는 것 같습니다.
위 4가지 결론이 나왔고 저희팀은 React 내부의 정확한 비교 방법과 Zustand에서 왜 shallow라는 명칭을 사용하는지 이해할 수 있었습니다.
주니어들끼리 머리를 맞대고 논의한 내용이기 때문에 틀린 내용이 있을 수 있습니다. 과감하게 지적해주시면 감사하겠습니다.
참고
Zustand와 함께 일하기
React 얕은 비교에 대한 얕은 오해
How Does Shallow Comparison Work In React?
shallow [zustand github]
Prevent rerenders with useShallow
(번역) 블로그 답변: React 렌더링 동작에 대한 (거의) 완벽한 가이드#컴포넌트 렌더링 최적화 기법