사이드 프로젝트인 독서 레벨 테스트에 Zustand를 도입하여 사용해 왔다. 단순히 '사용'하기 위해 공부를 했지만 이번 기회에 Deep하게 다양한 개념들을 정리하고 가려한다.
Zustand
A small, fast, and scalable bearbones state management solution. Zustand has a comfy API based on hooks. It isn't boilerplatey or opinionated, but has enough convention to be explicit and flux-like. - docs
공식 문서를 살펴보면 작고 빠르고 확장성이 뛰어난 상태 관리 솔루션으로 hook 기반으로 익숙하고 편안한 API를 제공한다. boilerplatey(무의미한 반복 코드)가 없고 opinionated(특정한 방식의 접근과 스타일을 따르도록 강제함)가 없지만 충분한 컨벤션이 있어 명확하고 Flux와 유사한 패턴을 가지고 있다고 한다.
Basic Usage
import { create } from "zustand";
type TBearStoreState = {
bears: number;
increasePopulation: () => void;
removeAllBears: () => void;
};
const useBearStore = create<TBearStoreState>()((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
기본적인 사용법은 먼저 create 함수를 이용하여 store를 생성한다. TypeScript를 사용할 때의 차이점은 create(...) 대신에 create<T>()(...)를 작성해야 한다.
const bears = useBearStore((state) => state.bears);
const increasePopulation = useBearStore((state) => state.increasePopulation);
const removeAllBears = useBearStore((state) => state.removeAllBears);
// OR
const { bears, increasePopulation, removeAllBears } = useBearStore();
store 훅을 컴포넌트에서 호출하여 사용한다. userBearStore의 구조 분해 할당을 통해 값들을 불러오지만 이러한 형태는 store의 전체 상태를 불러오기에 불필요한 리렌더링을 야기할 수 있으니 피하는 것이 좋다. 이 부분에 대해선 나중에 자세히 다루어보자.
get(), set()
export const useBearStore = create<TBearStoreState>()((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}));
데이터를 업데이트하는 것은 간단하다. set 함수를 호출하면 store의 기존 상태와 얕게 병합된다. 위 bears처럼 첫번째 Level 즉, depth가 1수준인 store인 경우 자동적으로 병합되고, 더 깊은 중첩된 구조의 상태라면 spread operator(...)를 통해 상태를 업데이트 해야 한다.
type State = {
deep: {
nested: {
obj: { count: number }
}
}
}
normalInc: () =>
set((state) => ({
deep: {
...state.deep,
nested: {
...state.deep.nested,
obj: {
...state.deep.nested.obj,
count: state.deep.nested.obj.count + 1
}
}
}
})),
이렇게 중첩된 상태를 이용할 경우 Immer Middleware를 이용하여 중첩된 객체에 대한 상태의 업데이트를 개선할 수 있다. Immer는 구조가 복잡한 객체도 매우 쉽고 짧은 코드를 사용하여 불변성을 유지하면서 업데이트하기 위해 사용하는 라이브러리이다.
불변성에 관한 이야기
불변성(Immutability)은 데이터나 객체가 변경 불가능한 상태를 유지하는 것을 의미한다. 다시 말해, 한 번 생성된 데이터나 객체는 변경할 수 없고, 변경하고자 하는 경우에는 새로운 데이터나 객체를 생성한다. 객체나 배열과 같은 복합 데이터 유형은 메모리에서 힙(heap) 영역에 저장된다. 이 힙 영역에 저장된 데이터는 메모리 주소를 통해 참조되고 변수는 해당 데이터의 메모리 주소를 가리키게 된다.
이러한 불변성은 React에서 상태 관리와 성능 최적화에 필수적인 요소다. React는 가상 DOM(virtual DOM)을 사용하여 실제 DOM 조작을 최소화하고 성능을 향상시킨다. 가상 DOM은 상태 변경 시 이전과 새로운 상태를 비교하여 변경된 부분만 실제 DOM에 반영한다. React는 불변성을 활용하여 상태 변경을 수행하며, 이는 새로운 객체 생성을 통해 이루어진다. 새로운 객체 생성은 이전 상태를 변경하지 않기 때문에 React는 두 상태 객체의 참조만 비교하여 변경 여부를 판단할 수 있다. 예를 들어, state.push(10)을 통해 배열에 10을 직접 추가하면 값은 변경되지만 state 새로운 참조 값으로 바뀐것이 아니기에 React는 리렌더링 하지 않는다.
Immer
Immer는 mutable하게 변경하는 객체를 immutable하게 데이터를 반환해주는 기능을 한다.
객체를 immutable하게 업데이트 한다는 것은 기존 객체를 새로운 객체로 복사한다는 것. 즉, 복사 비용이 발생한다. immer는 객체를 복사할 때 변경되지 않은 참조(reference)는 재사용하는 structural sharing 방식을 사용해 객체를 복사한다.
*structural sharing: 객체를 copy할 때 변경되지 않은 객체는 reference를 동일하게 사용하는 방식.
produce(baseState, recipe: (draftState) => void): nextState
immer의 produce는 첫번째 인자로 기존의 상태를 받고, 두번째 인자로 변경 레시피를 받는다. 레시피는 상태를 어떻게 변경할지에 대한 설명으로, 변경 사항이 반영된 새로운 상태를 반환하는 것이 아닌 기존 상태를 수정하지 않으면서 변경 사항을 반영한 새로운 상태를 생성한다. 레시피 함수는 일반적으로 아무것도 반환하지 않으나 필요한 경우 전체 상태 객체를 다른 객체로 대체하여 반환할 수 있다(returning new data). immer의 동작 원리
immerInc: () =>
set(produce((state: State) => { ++state.deep.nested.obj.count })),
zustand에서 immer를 사용하여 중접 객체에 대해 불변성을 유지하면서 상태를 업데이트한 예시다.
sum: () => {
const total = get().deep.nested.obj.count + get().nested.count;
return total;
},
get 함수는 set함수 외부에서 state의 값에 접근할 수 있다.
useShallow
대체제로 shallow 옵션이 있지만 useShallow가 비교적 최신 버전이며 사용이 권장된다. useShallow는 리렌더링를 방지하기 위해 사용된다. 공식 문서의 설명에 따르면 "상태를 store에서 계산된 상태를 구독해야 할 때, 추천하는 방법은 selector를 사용하는 것입니다. 계산된 selector는 출력이 Object.is에 따라 변경된 경우에만 다시 렌더링됩니다. 이 경우, 계산된 값이 항상 이전 값과 얕은 동등성을 유지하는 경우 다시 렌더링을 피하기 위해 useShallow를 사용할 수 있습니다." 라고 설명하고 있다.
Recipe
이는 가능한지만 상태가 변경될 때마다 컴포넌트가 업데이트가 된다는 점을 명심하자.
const state = useBearStore()
여러 상태 조각 선택
기본적으로 strict-equality(old === new)로 변경 사항을 감지하므로 amotic한 state를 선택하는데에 효율적이다.
const nuts = useBearStore((state) => state.nuts)
const honey = useBearStore((state) => state.honey)
Redux의 mapStateToProps와 유사한 기능을 하는 것처럼, 여러 state-picks를 내부에 가진 단일 객체를 구성할 때 useShallow를 사용하여 선택기의 출력이 변경되지 않을 때 불필요한 리렌더링을 방지할 수 있다.
import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'
const useBearStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))
// Object pick, state.nuts 또는 state.honey가 변경되면 컴포넌트가 리렌더
const { nuts, honey } = useBearStore(
useShallow((state) => ({ nuts: state.nuts, honey: state.honey })),
)
// Array pick,state.nuts 또는 state.honey가 변경되면 컴포넌트가 리렌더
const [nuts, honey] = useBearStore(
useShallow((state) => [state.nuts, state.honey]),
)
// Mapped picks, state.treats의 순서, 개수, 키가 변경될 때 컴포넌트 리렌더
const treats = useBearStore(useShallow((state) => Object.keys(state.treats)))
상태 덮어쓰기
set 함수의 두번째 인자는 기본적으로 false다. true로 설정하면 merge 대신에, 상태를 replace한다. action과 같이 의존하는 부분을 지우지 않도록 주의해야 한다.
import omit from 'lodash-es/omit'
const useFishStore = create((set) => ({
salmon: 1,
tuna: 2,
fishies: {},
deleteEverything: () => set({}, true), // action을 포함한 스토어 전체를 clear
deleteTuna: () => set((state) => omit(state, ['tuna']), true),
fetch: async (pond) => {
const response = await fetch(pond)
set({ fishies: await response.json() })
},
}))
컴포넌트 외부의 변화에 대한 읽기/쓰기 상태 및 대응
가끔 상태에 non-reactive한 방식으로 상태에 접근하거나 store에 대응해야할 때가 있다. 이런 경우를 위해, 생성된 hook 함수에는 해당 프로토타입에 연결된 유틸리티 함수가 있다.
*이 기술은 RSC에서는 추천하지 않는 방식이다.
const useDogStore = create(() => ({ paw: true, snout: true, fur: true }))
// non-reactive 새 상태 가져오기
const paw = useDogStore.getState().paw
// 모든 변경 사항을 듣고, 모든 변경 사항이 발생할 때마다 동기적으로 작동한다.
const unsub1 = useDogStore.subscribe(console.log)
// 상태를 업데이트하면, listeners가 트리거 된다.
useDogStore.setState({ paw: false })
// Unsubscribe listeners
unsub1()
// 항상 그랬던 것처럼 hook를 사용할 수 있다.
function Component() {
const paw = useDogStore((state) => state.paw)
...
Persist Middleware
store의 데이터를 storage에 저장할 수 있는 기능을 제공한다.
persist로 감싸주고, 옵션 객체를 지정해주면 된다.
🟢Persist Options
name
필수 옵션으로 storage key가 되므로 유니크 해야 한다.
storage
Type: () => StateStorage
Default: createJSONStorage(() => localStorage) - 기본 값은 로컬 스토리지다.
사용하고자 하는 저장소를 반환하는 함수를 전달하기만 하면 된다. StateStorage 인터페이스와 호환되는 저장소 객체를 생성하는 createJSONStorage helper 함수를 사용하는 것이 권장된다.
partialize
Type: (state: Object) => Object
Default: (state) => state
storage에 저장할 상태 필드를 선택할 수 있습니다. 필요한 상태만 저장 가능합니다!
export const useBoundStore = create(
persist(
(set, get) => ({
foo: 0,
bar: 1,
}),
{
// ...
partialize: (state) =>
Object.fromEntries(
Object.entries(state).filter(([key]) => !['foo'].includes(key)),
),
// OR
partialize: (state) => ({ foo: state.foo }),
},
),
)
onRehydrateStorage
Type: (state: Object) => ((state?: Object, error?: Error) => void) | void
storage가 hydration될 때 호출되는 리스너 함수를 전달할 수 있다.
onRehydrateStorage: (state) => {
console.log('hydration starts')
// optional
return (state, error) => {
if (error) {
console.log('an error happened during hydration', error)
} else {
console.log('hydration finished')
}
}
},
version
Type: number, Default: 0
storage에 대한 주요 변경 사항을 적용하려는 경우 새 버전을 지정할 수 있습니다. migrate 옵션과 함께 사용하면 유용할 것 같습니다.
merge
Type: (persistedState: Object, currentState: Object) => Object
Default: (persistedState, currentState) => ({ ...currentState, ...persistedState })
경우에 따라, custom merge 함수를 이용해 storage에 저장된 값과 현재 상태를 병합하기를 원할 수 있다. 기본적으로 middleware는 얕은 병합(shallow merge)을 수행하지만 부분적으로 persisted된 중첩 객체는 얕은 병합으론 부족하기에 merge 함수를 이용해 커스텀한 병합을 수행한다.
{
foo: {
bar: 0,
}
}
storage에는 위처럼 저장되어 있고 store에는 아래처럼 저장되어 있다면.
{
foo: {
bar: 0,
baz: 1,
}
}
얕은 병합은 baz field를 지운다. 이를 고치기 위한 한가지 방법으로 custom deep merge 함수를 제공한다.
merge: (persistedState, currentState) =>
deepMerge(currentState, persistedState),
},
Subscribe Middleware
zustand에서 subscribe의 간단한 의미는 컴포넌트 리렌더링 없이 global state notification이다. 이게 무슨 말이냐면, 전역 상태가 변경될 때 컴포넌트를 리렌더링하지 않고도 해당 변경 사항에 대해 알림을 받을 수 있다. 이는 global state가 변경되었을 때 각 컴포넌트가 필요한 경우에만 리렌더링 하도록 허용하여 불필요한 리렌더링을 피해 성능을 향상시킬 수 있다. 오직 프론트엔드 컴포넌트나 함수 간에 상태 변경을 전달하기 위해 사용된다.
🙅♂️ Subscribe를 사용하지 않는 예시
export const BearBox = () => {
const { bears, increasePopulation, removeAllBears } = useBearStore();
const fish = useFoodStore((state) => state.fish);
return (
<div
className="box"
style={{ backgroundColor: fish > 5 ? "lightgreen" : "lightpink" }}
>
<h1>BearBox</h1>
<p>bears: {bears}</p>
<p>{Math.random()}</p>
<div>
<button onClick={increasePopulation}>add bear</button>
<button onClick={removeAllBears}>remove bear</button>
<button onClick={useBearStore.persist.clearStorage}>
clear storage
</button>
</div>
</div>
);
};
BearBox에서 fish를 select하여 사용하고 있지만 fish가 변할때 마다 BearBox가 리렌더링 되고 있다. 하지만 fish가 5보다 클 경우 색상을 변경하고 있기에 fish > 5 인 경우에만 리렌더링을 수행하면 된다. 이를 Subscribe를 이용하여 해결해보자.
✅ Subscribe
subscribe는 콜백 함수 형태의 listener를 받는다. listener는 현재 상태와, 이전 상태 두 개의 매개변수를 받는다.
useFoodStore.subscribe((state, prevState) => {
console.log(state, prevState);
});
이 코드는 store의 state가 변경될 때마다 호출된다. 하지만 컴포넌트 리렌더링을 야기시키지는 않기에 이 기능을 활용하여 불필요한 리렌더링을 줄일 수 있다. subscribe는 컴포넌트 외, 내부에서 호출할 수 있는데 컴포넌트 내부에서 호출할 경우 useEffect을 이용하여 호출하는 것이 낫다.
// BearBox Comp
// const fish = useFoodStore((state) => state.fish);
useEffect(() => {
const unsub = useFoodStore.subscribe((state, prevState) => {
console.log(state, prevState);
});
return unsub;
}, []);
subscribe가 반환하는 unsub를 반환하는 것은 컴포넌트를 떠날 때 해당 store의 구독을 취소(unsubscribe)한다는 의미다.
BearBox의 리렌더링이 발생하지 않고도 fish의 state가 정확하게 출력되고 있다.
const [bgColor, setBgColor] = useState<"lightgreen" | "lightpink">(
"lightpink"
);
useEffect(() => {
const unsub = useFoodStore.subscribe((state, prevState) => {
if (prevState.fish <= 5 && state.fish > 5) {
setBgColor("lightgreen");
} else if (prevState.fish > 5 && state.fish <= 5) {
setBgColor("lightpink");
}
});
return unsub;
}, []);
return (
<div className="box" style={{ backgroundColor: bgColor }}>
...
);
잘 작동한다.
✅ subscribeWithSelector()
많은 state를 가지고 있지만 일부만 관심이 있다고 한다면 subscribeWithSelector를 이용하면 된다. subscribeWithSelector를 사용하기 위해선 다른 middleware처럼 create 내부에 감싸주면 된다.
export const useFoodStore = create<TFoodState>()(
subscribeWithSelector((set) => ({
subscribe의 내용이 바뀌었다. 이에 맞게 작성해보자.
useEffect(() => {
const unsub = useFoodStore.subscribe(
(state) => state.fish,
(fish, prevFish) => {
if (prevFish <= 5 && fish > 5) {
setBgColor("lightgreen");
} else if (prevFish > 5 && fish <= 5) {
setBgColor("lightpink");
}
},
{ equalityFn: shallow, fireImmediately: true }
);
return unsub;
}, []);
똑같이 동작하지만 console로 출력되는 양이 subscribeWithSelector가 더 적었다.
🟢 options
equalityFn
fireImmediately
true라면 subscribe 함수 내의 콜백 함수를 한번 실행 시킨다.
// subscribeWithSeletor code 일부
if (options?.fireImmediately) {
optListener(currentSlice, currentSlice)
}
Middleware Order
export const useOrderStore = createSelectors(
create<State>()(
immer(
devtools(
subscribeWithSelector(
persist(
(set, get) => ({
순서가 크게 중요하지 않지만 공식 문서에서는 immer(devtools(...))를 권장한다.
setState(), getState()
setState는 set과 비슷하지만 store 바깥에서 사용된다는 차이점이 있다.
const { fish, addOneFish, removeAllFish, removeOneFish } = useFoodStore();
const add5Fish = () => {
useFoodStore.setState((state) => ({
fish: state.fish + 5,
}));
};
setState를 이용해 store에 선언하지 않은 새로운 함수를 만들어 상태를 업데이트 해줄 수 있다.
// const fish = useFoodStore((state) => state.fish);
const fish = useFoodStore.getState().fish; // non-reactive
getState로 얻은 fish는 non-reactive라는게 무슨 말일까? 만약 store의 fish state가 변경되어도 fish 변수는 변경 사항을 알지 못한다. 즉, state는 변경되지만 해당 fish의 값은 변하지 않기에 리렌더링도 발생하지 않는다.
그렇다면 이 getState는 어디에 쓰일까?
const [bgColor, setBgColor] = useState<
"lightgreen" | "lightpink" | undefined
>(useFoodStore.getState().fish > 5 ? "lightgreen" : "lightpink");
bgColor의 초기 값을 설정하기 위해 초기 fish의 고정 값을 받아오는 데에 사용했다.
export const useBoundStore = create((set) => ({
count: 0,
text: 'hello',
inc: () => set((state) => ({ count: state.count + 1 })),
setText: (text) => set({ text }),
}))
기존에는 state와 action이 함께 포함된 독립형 스토어가 생성된다. 다른 접근 방법을 살펴보자.
export const useBoundStore = create(() => ({
count: 0,
text: 'hello',
}))
export const inc = () =>
useBoundStore.setState((state) => ({ count: state.count + 1 }))
export const setText = (text) => useBoundStore.setState({ text })
저장소 외부의 모듈 레벨에서 action을 정의한다. 다시 말자하면, 상태 변경 함수들을 별도의 모듈로 분리하여 상태 저장소 외부에서 관리하는 것이다. 이렇게 함으로써 몇가지 장점이 있다.
- action을 호출하는데 hook이 필요하지 않는다.
- 코드 분할(splitting)이 용이하다.
zustand 코드 Slice Pattern 적용하여 실 사용하기
진행 중
'개발자의 공부 > React' 카테고리의 다른 글
[React] 유연하고 재사용 가능한 버튼 컴포넌트 만들기 with tailwindcss (0) | 2024.01.06 |
---|---|
React Hook Form을 이용하여 폼 관리(+ zod, ts, nextjs13) (0) | 2023.11.22 |
이미지 최적화에 대한 명확한 가이드 (0) | 2023.02.01 |
프론트엔드 최적화 시도 - 1 (0) | 2023.01.30 |
[React]useRef (0) | 2023.01.28 |