최근 인기있는 상태 관리 라이브러리 Zustand를 어느정도 사용해보면서, store를 어떻게 선언할 것인가에 대해 고민하고 실험해보았다.
공식문서의 첫 예시 코드에서도 상태와 동작이 함께 선언되어 있는 것을 보면, 이 라이브러리의 상태관리에 대한 접근법을 엿볼 수 있다. Zustand store는 연관있는 상태와 동작을 캡슐화한 객체로 생각할 수 있다.
import { create } from 'zustand';
export interface CounterStore {
count: number;
increase: () => void;
}
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increase: () => set(state => ({ count: state.count + 1 }))
}));
const Counter = () => {
const counterStore = useCounterStore();
// ...
};
이렇게 선언하면, store라는 하나의 개념을 통해 상태 자체와 상태를 조작하는 동작을 함께 관리할 수 있으므로 인지적으로 단순해지는 장점이 있을 수 있다.
zustand 공식문서는 store에서 상태와 동작을 같이 선언하는 것을 권장한다. 하지만 이는 권장 사항일 뿐 강제는 아니다. store에는 상태만 저장하고, 동작은 별개로 선언해서 사용할 수 있다.
// counterStore.ts
import { create } from 'zustand';
export interface CounterStore {
count: number;
}
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
}));
export const increaseCount = () => {
useCounterStore.setState(state => ({ count: state.count + 1 }));
};
// CounterIncreaseButton.tsx
import { increaseCount } from './counterStore.ts';
const CounterIncreaseButton = () => {
return (
<button onClick={increaseCount}>+</button>
);
};
상태와 동작의 분리의 필요성은 공식문서에서도 느껴지는데, 예시코드에서 State
와 Actions
타입을 별개로 선언한 것을 보면 그렇다.
이렇게 사용했을 때의 장점은 여러 가지가 있다.
Zustand.create()
의 파라미터에는 상태를 선언하고, 외부에 각 동작을 하나의 함수로 선언함으로써 각 동작을 분리하여 읽기 좋아진다.
더 나아가 initialState
변수를 별도로 선언하고, Zustand.create()
의 파라미터는 미들웨어의 동작을 설정하기 위한 위치로 생각할 수도 있다.
import { create } from 'zustand';
import { persist, createJSONStorage } from "zustand/middleware"
import { immer } from "zustand/middleware/immer"
// 타입 선언
interface CounterStore {
count: number;
}
// 상태 선언
const initialState: CounterStore = {
count: 0
}
// 미들웨어 설정
const useCounterStore = create(
persist(
immer(initialState),
{
name: 'zustand/counter',
storage: createJSONStorage(() => sessionStorage)
}
)
)
// 동작 선언
const set = useCounterStore.setState;
const get = useCounterStore.getState;
const increase = () => {
// immer middleware 사용 시, 외부에서도 immer의 기능 사용 가능
set(state => {
state.count += 1
});
};
export const counterActions = {
increase,
};
각 동작은 실행 시점에 store.getState()
를 호출하여 상태를 참조하므로 store 내부의 상태와 무관한 함수다. 따라서 동작을 호출하기 위해 컴포넌트 내부에 훅을 사용할 필요가 없다. 불필요한 컴포넌트 리렌더링을 방지하고 동작만을 참조해 사용하기 위해 작성하는 selector 함수도 작성할 필요가 없으므로 코드 작성량이 제법 줄어드는 효과도 있다.
import { useCounterStore } from './counterStore.ts';
const CounterIncreaseButton = () => {
const increase = useCounterStore(store => store.increase);
return (
<button onClick={increase}>+</button>
);
};
import { counterActions } from './counterStore.ts';
const CounterIncreaseButton = () => {
return (
<button onClick={counterActions.increase}>+</button>
);
};
Zustand는 persist 미들웨어를 기본적으로 제공한다. 그런데 persist할 때 sessionStorage / localStorage를 사용할 경우 직렬화 가능한 값만 유지되므로, 직렬화되지 않는 함수(동작)들은 store에서 사라지게 된다.
// counterIncreaseButton.tsx
import { useCounterStore } from './counterStore.ts';
const CounterIncreaseButton = () => {
// 실행 시 undefined is not a function 오류
const increase = useCounterStore(store => store.increase);
return (
<button onClick={increase}>+</button>
);
};
이를 방지하기 위해 persist 미들웨어에는 merge라는 옵션이 추가되었는데, persisted state를 in-memory state에 동기화시키는 hydration merge를 커스텀 함수로 선언할 수 있게 한 것이다.
이를 이용하면 다음과 같이 문제를 해결할 수 있다. 커스텀 함수는 라이브러리를 사용하거나 직접 구현할 수 있다.
import { mergeLeft } from 'ramda';
const useCounterStore = create(
persist(
immer(initialState),
{
merge: (persistedState, currentState) =>
mergeLeft(currentState, persistedState),
}
)
);
문제는 해결되었지만 그냥 persist만 하기 위해 이런 걸 일일히 작성해야 한다는 건 귀찮고도 슬픈 일이다...
이 문제를 해결하는 다른 방법은 그냥 함수를 store에 저장하지 않는 것이다.
아직은 Zustand와 함께한 시간 자체가 많지 않기 때문에, 상태와 동작을 분리했을 때의 큰 단점을 찾지 못했다. 당분간은 위와 같은 방식을 best practice로 생각하고 사용해보려고 한다.