Zustand를 사용하다 보면 상태는 바뀌지 않았는데 컴포넌트가 리렌더링되는 상황을 만날 수 있습니다.
특히 여러 상태를 한 번에 가져오기 위해 selector에서 객체나 배열을 반환할 때 자주 발생합니다.
const {name, age} = useUserStore((state) => ({
name: state.name,
age: state.age,
}));겉으로 보기에는 name과 age만 구독하는 것처럼 보입니다. 하지만 이 selector는 실행될 때마다 새로운 객체를 만듭니다.
{ name: 'Kim', age: 30 } !== { name: 'Kim', age: 30 }위와 같이 객체의 내용이 같아도 참조가 다르기 때문에 Zustand는 selector 결과가 바뀌었다고 판단할 수 있습니다. 이 문제를 해결하기 위해 사용하는 훅이 useShallow입니다.
useShallow란?
useShallow는 selector의 반환값을 얕은 비교(shallow comparison)로 비교해주는 훅입니다.
import {useShallow} from 'zustand/shallow';
const {name, age} = useUserStore(
useShallow((state) => ({
name: state.name,
age: state.age,
})),
);이렇게 작성하면 selector가 매번 새로운 객체를 반환하더라도, 객체 안의 최상위 값들이 이전과 같다면 이전 결과를 재사용합니다.
즉, 불필요한 리렌더링을 줄일 수 있습니다.
Zustand의 기본 비교 방식
Zustand는 기본적으로 selector 결과를 Object.is 기준으로 비교합니다.
const name = useUserStore((state) => state.name);이 경우 name이 문자열이라면 값 자체가 바뀔 때만 리렌더링됩니다.
Object.is('Kim', 'Kim'); // true
Object.is(1, 1); // true
Object.is(true, true); // true원시값은 비교가 단순하다. 그래서 하나의 원시값만 구독할 때는 별도 최적화가 필요 없는 경우가 많습니다.
문제는 selector가 객체나 배열을 반환할 때 입니다.
const userInfo = useUserStore((state) => ({
name: state.name,
age: state.age,
}));이 selector는 store가 갱신될 때마다 새로운 객체를 만든다. 내부 값이 같아도 참조가 달라집니다.
Object.is({name: 'Kim'}, {name: 'Kim'}); // false그래서 여러 값을 묶어서 가져올 때 useShallow가 의미를 가집니다.
useShallow가 해결하는 문제
가령 다음과 같은 store가 있다고 예를 들면
const useUserStore = create<UserState>((set) => ({
name: 'Kim',
age: 30,
theme: 'light',
setName: (name) => set({name}),
setTheme: (theme) => set({theme}),
}));아래의 컴포넌트와 같이 name, age만 가져와 사용하는데도 다른 컴포넌트에서 theme를 변경하면 자동으로 객체가 변경되었다고 판단하여 리렌더링을 진행합니다.
function UserProfile() {
const {name, age} = useUserStore((state) => ({
name: state.name,
age: state.age,
}));
console.log('render'); // theme이 바뀔때도 log 발생
return (
<div>
{name} / {age}
</div>
);
}useShallow는 객체안의 프로퍼티를 얕은비교하여 이전과 같다면 렌더링을 막아주기 때문에 다음과 같이 useShallow를 적용하면 리렌더링이 발생하지 않습니다.
import {useShallow} from 'zustand/shallow';
function UserProfile() {
const {name, age} = useUserStore(
useShallow((state) => ({
name: state.name,
age: state.age,
})),
);
console.log('render'); // theme이 바뀔때는 log 미발생
return (
<div>
{name} / {age}
</div>
);
}한계
정리한대로 useShallow는 말그대로 얕은비교만 진행합니다. 즉 객체의 최상위값만 비교를 진행하여 뎁스가 깊은 객체 자체를 selector 결과로 반환하면 내부 값까지 비교하지 못합니다.
// store
const useUserStore = create<UserState>((set) => ({
user: {
profile: {
address: {
city: null
}
}
}
}));
// 컴포넌트
const {profile} = useStore(
useShallow((state) => ({
profile: state.user.profile,
})),
);
// 아래의 경우 profile이 새로운 객체가 되어 리렌더링 가능
set((state) => ({
user: {
...state.user,
profile: {
...state.user.profile,
address: {
...state.user.profile.address,
city: 'Seoul',
},
},
},
}));
// 아래의 경우 리렌더링이 발생하지 않을 수 있음
useStore.setState((state) => {
state.user.profile.address.city = 'Seoul';
return state;
});정리
useShallow는 Zustand에서 selector가 반환하는 객체나 배열을 얕게 비교해 불필요한 리렌더링을 줄이는 도구입니다.
하지만 모든 렌더링 문제를 해결하는 만능 도구는 아닙니다. 얕은 비교는 최상위 값만 비교하기 때문에, 중첩 객체나 매번 새로 만들어지는 배열 내부 객체까지 비교하지 않습니다.

댓글 0