Zustand로 상태관리를 하다 보면 의도치 않은 리렌더링이 발생할 수 있습니다. 이런 문제를 해결하기 위해 Zustand 가 제공하는 useShallow
를 사용하곤 합니다.
useShallow
가 왜 필요하고 어떤 상황에서 사용해야 하는지에 대해 알아보았습니다.
먼저 간단한 유저 스토어를 만들어보겠습니다.
const useUserStore = create(set => ({
name: 'John',
company: 'Google',
age: 20,
hobbies: ["운동", "게임", "코딩"]
increaseAge: () => set((state) => ({ age: state.age + 1 }))
}))
유저 스토어를 구독하고 있는 컴포넌트가 여러 state 에 대해 구독하고 있다면, 다음과 같이 쉽고 편하게 구조 분해 할당으로 가져올 수 있을 것 같습니다.
// Bad: 구조 분해 할당
const Component = () => {
const { name, company } = useUserStore()
console.log("rerender!")
return <div>{name}: {company}</div>
}
// age만 변경
useUserStore.increaseAge()
// name을 사용하지 않는데도 리렌더링 발생! ("rerender!" 출력)
그런데 이렇게 구조 분해 할당을 사용하고 selector 없이 호출하여 사용하는 경우 store 전체를 구독하게 됩니다. 컴포넌트가 구독하고 있는 값인 name
, company
이 아니라 age
값을 변경하였는데도 의도하지 않게 리렌더링이 발생합니다.
따라서 이러한 경우 selector
를 사용해 필요한 값만 선택하는 것이 좋습니다.
// Good: selector로 필요한 값만 선택
const Component = () => {
const name = useUserStore(state => state.name)
const company = useUserStore(state => state.company)
console.log("rerender!")
return <div>{name}</div>
}
// age만 변경
useUserStore.increaseAge()
// name을 구독하고 있지만 age만 변경되었으므로 리렌더링 발생하지 않음
위 코드에서 selector
를 사용하여 각각의 값을 개별적으로 구독하므로 name
이나 company
가 아닌 다른 값이 변경될 때는 리렌더링이 발생하지 않습니다.
하지만 컴포넌트에 구독하는 state 가 매우 많다면 코드가 장황해지고, 상태 변화를 추적하기 어려워질 수 있습니다. 이런 경우 useShallow
를 사용하면 여러 state 를 한 번에 구독하면서도 불필요한 리렌더링을 방지할 수 있습니다.
공식 문서에서는 useShallow
를 사용하여 여러 state를 얻거나, 단일 상태를 구독하는 커스텀 훅을 재사용 할것을 권장하고 있습니다.
// 1️⃣ 컴포넌트에서 useShallow로 여러 상태 구독
const Component = () => {
const { name, company } = useUserStore(
useShallow((state) => ({
name: state.name,
company: state.company
}))
);
return <div>{name}: {company}</div>;
};
// 2️⃣ 단일 상태를 추출하는 커스텀 훅 생성
export const useName = () => useUserStore((state) => state.name);
export const useCompany = () => useUserStore((state) => state.company);
const Component = () => {
// 컴포넌트에서 selector 작성 로직이 제거됨
const name = useName();
const company = useCompany();
return <div>{name}: {company}</div>;
};
하지만 여기서 주의할 점이 있습니다. selector가 반환하는 값의 종류에 따라 useShallow의 필요성이 달라집니다.
// 1️⃣ 원시값을 반환하는 경우 - useShallow 필요없음
export const useName = () => useUserStore((state) => state.name);
export const useAge = () => useUserStore((state) => state.age);
// 2️⃣ 새로운 객체를 반환하는 경우 - useShallow 필요!
export const useUserInfo = () => useUserStore((state) => ({
name: state.name,
company: state.company
}));
// ❌ 매번 새로운 객체가 생성되어 불필요한 리렌더링 발생
// ✅ useShallow 사용으로 최적화
export const useUserInfo = () => useUserStore(
useShallow((state) => ({
name: state.name,
company: state.company
}))
);
즉, selector가 새로운 객체나 배열을 생성하여 반환할 때는 반드시 useShallow를 사용해야 불필요한 리렌더링을 방지할 수 있습니다.
useShallow는 내부적으로 shallow 비교 함수를 사용하여 이전 상태와 현재 상태의 차이를 감지합니다. 이 비교 과정은 크게 세 단계로 이루어집니다.
먼저 Object.is
를 사용하여 기본적인 비교를 수행합니다.
if (Object.is(valueA, valueB)) {
return true
}
if (
typeof valueA !== 'object' ||
valueA === null ||
typeof valueB !== 'object' ||
valueB === null
) {
return false
}
이 단계에서는 두 값이 정확히 같은 참조를 가지는지 확인하고, 둘 중 하나라도 객체가 아니거나 null
인 경우 false
를 반환합니다.
객체인 경우 최상위 속성들을 비교합니다.
const compareEntries = (
valueA: { entries(): Iterable<[unknown, unknown]> },
valueB: { entries(): Iterable<[unknown, unknown]> },
) => {
// Map 인스턴스이면 그대로 사용, 일반 객체의 경우 `Object.entries()`로 얻은 key-value 쌍을 Map으로 변환
const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries())
const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries())
// 객체의 크기(프로퍼티 개수)가 다르면 false
if (mapA.size !== mapB.size) {
return false
}
// 각 프로퍼티의 값을 Object.is로 비교
for (const [key, value] of mapA) {
if (!Object.is(value, mapB.get(key))) {
return false
}
}
return true
}
객체들을 비교할 때는 Map
자료구조를 활용하여 객체의 프로퍼티들을 비교합니다. 객체의 모든 프로퍼티를 Map
으로 변환한 뒤, 크기가 다르거나 같은 키의 값이 다르면 false를 반환합니다.
이렇게 하여 객체의 최상위 프로퍼티들의 변경을 감지할 수 있습니다.
const compareIterables = (
valueA: Iterable<unknown>,
valueB: Iterable<unknown>,
) => {
// 각각의 Iterator 가져오기
const iteratorA = valueA[Symbol.iterator]()
const iteratorB = valueB[Symbol.iterator]()
// next 메서드로 순차적으로 값에 접근
let nextA = iteratorA.next()
let nextB = iteratorB.next()
// 두 Iterator를 동시에 순회하면서 Object.is로 값 비교
while (!nextA.done && !nextB.done) {
if (!Object.is(nextA.value, nextB.value)) {
return false
}
nextA = iteratorA.next()
nextB = iteratorB.next()
}
// 둘 다 순회가 끝났는지 확인
return !!nextA.done && !!nextB.done
}
배열과 같은 순회 가능한(iterable) 객체들을 비교할때는 Iterator
를 활용하여 배열의 각 요소를 비교 값이 다르거나, 순서가 다르거나 길이가 다르면 false를 반환합니다.
이렇게 하여 배열이나 Set 같은 iterable 객체의 변경을 감지할 수 있습니다.
- Zustand에서 상태를 구독할 때는
useShallow
를 사용하여 여러 상태를 구독하고, 불필요한 리렌더링을 방지할 수 있습니다. - 하지만
useShallow
는 엄격한 동등성 검사를 하는Object.is
와 달리 객체의 최상위 속성만 비교하는 얕은 비교만 수행하므로, 중첩된 객체의 깊은 변화는 감지하지 못할 수 있습니다.