[Zustand] 스토어 값 한 번에 여러개 가져오기 (ft. useShallow)
"react": "^18.2.0",
"zustand": "^4.5.2"
"typescript": "^5.2.2",
스토어 값 여러 개가 필요한 경우
이전 게시글 "[Zustand] Zustand 상태 관리 라이브러리 설치 및 기본 사용법"에서 스토어 값을 가져올 때 이런 식으로 사용했었다.
import { useCountStore } from '../store/countStore'
const CountButton = () => {
const increase = useCountStore((state) => state.increase)
const decrease = useCountStore((state) => state.decrease)
return (
<>
<button onClick={increase}>증가</button>
<button onClick={decrease}>감소</button>
</>
)
}
하지만 한 컴포넌트에서 하나의 스토어로부터 가져와야 하는 값이 여러 개인 경우 밑도 끝도 없이 늘어날 수 있으니 다른 방법은 없을까?
구조분해 할당 사용
단순하게 구조 분해 할당으로 선언하는 건 어떨까?
const CountButton = () => {
const { increase, decrease } = useCountStore()
return (
<>
<button onClick={increase}>증가</button>
<button onClick={decrease}>감소</button>
</>
)
}
하지만 이 방식에는 치명적인 이슈가 있다.
저 스토어에서 increase, decrease는 단순히 set을 하기위한 함수인데 state값을 바라보지 않음에도 불구하고 set함수가 실행될 때마다 해당 컴포넌트는 리렌더링이 발생한다.
이는 정말 단순한 이유인데 리액트에서 불변성을 유지하기 위해서 전개연산자로 나머지값들을 새롭게 선언하는 방식을 사용하기 때문이다. 이를 zustand에서는 생략되어 있어 모르고 사용한다면 예상치 못한 방식으로 동작할 수 있다.
공식문서에서 알려주는 코드 예제를 살펴보면 알 수 있다.
import { create } from 'zustand'
const useCountStore = create((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
이런식의 스토어가 있다고 해보자.
set((state) => ({ ...state, count: state.count + 1 }))
불변성을 유지하려면 이런 식으로 상태값을 유지해야 하는데, 이는 아주 일반적인 패턴으로 zustand에서는 생략해서 사용이 가능하다고 한다.
set((state) => ({ count: state.count + 1 }))
그래서 단순히 set을 하는 함수만 가져와서 쓴다고 해도 실제로 주소값이 변경되어 리렌더링이 발생하는 것이다.
그렇다면 어떻게 해야 할까?
useShallow 사용
공식문서의 Selecting multiple state slices 세션을 살펴보자.
shallow의 의미 그대로 얕은 복사를 해주는 거라 가져오는 프로퍼티의 주소가 변경되더라도 실제 값이 변경되지 않는 이상 불필요한 리렌더링이 발생하지 않는다.
// 배열 형태로 여러 개의 state를 가져오는 방식
const [ increase, decrease ] = useCountStore(useShallow((state) => [state.increase, state.decrease]))
// 객체 형태로 여러 개의 state를 가져오는 방식
const { increase, decrease } = useCountStore(
useShallow((state) => ({
increase: state.increase,
decrease: state.decrease,
}))
)
useShallow 사용 최소화
위 예제에서는 set 함수에 대해서만 작성했는데 당연히 실제 사용할 상태값에 대해서도 활용이 가능하다
하지만 그렇게 되면 useShallow가 굉장히 많아져 쓸데없이 코드가 길어질 수 있다.
그러면 set, get의 경우 useShallow 없이 리렌더링을 회피할 방법은 없을까?
생각보다 간단하다 주소값을 유지시키면 된다. 기본적으로 set을 통해 불변성을 유지하려고 구조분해할당을 활용하는데 이는 알다시피 깊은 복사가 아니라서 안에 있는 객체나 배열의 주소값이 유지될 수 있다.
그래서 단순하게 한 번 더 감싸서 사용해 주면 된다.
getter, setter로 감싸서 사용
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
type TCountStore = {
count: number
setter: {
increase: () => void
decrease: () => void
}
getter: {
getCount: () => number
}
}
export const useCountStore = create<TCountStore>()(
devtools((set, get) => ({
count: 0,
getter: {
getCount: () => get().count,
},
setter: {
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
},
}))
)
const CountButton = () => {
const { increase, decrease } = useCountStore((state) => state.setter)
return (
<>
<button onClick={increase}>증가</button>
<button onClick={decrease}>감소</button>
</>
)
}
actions로 전부 감싸서 사용
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
type TCountStore = {
count: number
actions: {
increase: () => void
decrease: () => void
getCount: () => number
}
}
export const useCountStore = create<TCountStore>()(
devtools((set, get) => ({
count: 0,
actions: {
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
getCount: () => get().count,
},
}))
)
const CountButton = () => {
const { increase, decrease } = useCountStore((state) => state.actions)
return (
<>
<button onClick={increase}>증가</button>
<button onClick={decrease}>감소</button>
</>
)
}
어떻게 감싸서 사용하냐는 논의 후 사용하면 된다.
이렇게 되면 함수의 경우에는 useShallow를 사용하지 않아도 불필요한 리렌더링이 발생하지 않아 좀 더 깔끔하게 사용이 가능하다.
추가적으로 얘기하자면 useShallow는 zustand 특정 버진을 기점으로 생긴거라서 useShallow가 없는 zustand 버전이라면 shallow 옵션이 있을테니 현재 버전에 맞춰서 사용하면 된다.