Zustand는 전역 상태 스토어를 생성하고 사용하는 간단한 구조를 가집니다. create
함수를 호출하여 하나의 스토어를 정의하면, 이 저장소 자체가 React 훅(hook)으로 반환됩니다. 따라서 Context Provider로 래핑할 필요 없이 애플리케이션 어디서나 이 훅을 불러 상태를 조회할 수 있습니다. 하나의 store 안에 원하는 상태 값들과 해당 상태를 변경하는 액션 함수들을 함께 정의하며, 필요에 따라 여러 개의 store를 분리하여 관리할 수도 있습니다.
Zustand의 set
함수는 전달된 부분 상태 객체를 현재 상태에 얕은 복사(shallow copy)로 병합하여 상태를 변경합니다. 변경된 부분만 새 객체로 만들어주기 때문에 상태 변경 감지가 가능해집니다. 단, 중첩된 객체를 업데이트할 때는 얕은 복사를 하기 때문에, 필요한 경우 직접 깊은 복사를 하거나 Immer 미들웨어를 사용해야 합니다.
Zustand의 큰 장점 중 하나는 선택적 구독을 통해 불필요한 리렌더링을 줄일 수 있다는 것입니다. zustand의 useStore
훅을 사용할 때 전체 상태를 가져오는 대신, selector 함수를 인자로 전달하여 특정 상태 값만 구독할 수 있습니다. 예를 들어 const count = useStore(state => state.count)
처럼 사용하면 해당 컴포넌트는 store의 count
값만을 바라보게 됩니다. 이 경우 store의 다른 값이 변경될 때는 컴포넌트가 리렌더링되지 않고, 오직 count
가 바뀔 때만 리렌더링이 일어납니다. 이는 selector 함수의 반환값을 이전 값과 비교하여 변경된 경우에만 컴포넌트를 갱신하기 때문입니다.
만약 selector가 객체나 배열 등 복합 자료형을 반환한다면, 얕은 비교가 필요할 수 있습니다. Zustand는 이를 위해 zustand/shallow
모듈에서 제공하는 얕은 비교 함수를 사용할 수 있게 합니다.
const createStoreImpl: CreateStoreImpl = (createState) => {
type TState = ReturnType<typeof createState>
type Listener = (state: TState, prevState: TState) => void
let state: TState // 스토어의 상태는 클로저로 관리 됨.
const listeners: Set<Listener> = new Set()
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
const getState: StoreApi<TState>['getState'] = () => state
const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api as any
}
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
스토어의 상태는 클로저로 관리 됩니다.
let state: TState // 스토어의 상태는 클로저로 관리
배열로 관리하게 될 경우 중복된 리스너에 대한 처리가 어렵기에 상태 변경을 구독할 리스너를 Set으로 관리합니다.
// 상태 변경을 구독할 리스너를 Set으로 관리한다.
const listeners = new Set();
현재 상태를 기반으로 새로운 상태를 리턴하는 함수 혹은, 아예 변경하려는 상태 값을 전달받습니다.
partial
: 새로운 상태 또는 상태를 변경하는 함수replace
: 상태를 특정 상태로 대체할 것인지 여부const setState: StoreApi<TState>['setState'] = (partial, replace) => {
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
다음 상태와 현재 상태가 다를 경우 state를 갱신하거나 대체합니다.
이후, 모든 리스너를 순회하며 모든 구독자에게 새로운 상태와 이전 상태를 전달합니다.
if (!Object.is(nextState, state)) { // 얕은 복사로 다음 상태와 현재 상태 비교
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
getState를 통해 현재 상태를 가져올 수 있고, getInitialState를 통해 초기 상태를 가져올 수 있습니다.
const getState: StoreApi<TState>['getState'] = () => state
const getInitialState: StoreApi<TState>['getInitialState'] = () =>
initialState
subscribe함수는 호출되면 인자로 들어온 함수를 listeners에 추가합니다.
구독을 해제할 수 있는 함수를 반환하여, 이를 호출하면 listeners에 추가된 함수를 제거시킵니다.
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
listeners.add(listener)
// Unsubscribe
return () => listeners.delete(listener)
}
// count 값의 변화만 구독하기
const unsubscribe = useCounterStore.subscribe(
state => state.count, // 구독할 부분: count 값
(currentCount, previousCount) => {
console.log(`Count 변경: ${previousCount} -> ${currentCount}`);
}
);
// 나중에 구독 해제
unsubscribe();
위와 같이 selector와 리스너 함수를 전달하여 subscribe하면, count
값이 바뀔 때마다 해당 콜백이 호출됩니다. 이 기능은 React 컴포넌트 바깥에서 상태 변화를 감지하여 별도의 사이드 이펙트를 처리하거나, 혹은 React로 관리하지 않는 UI 요소를 수동으로 업데이트할 때 유용합니다. Subscribe로 등록한 리스너는 set
을 통해 상태가 바뀔 때마다 호출되며, 필요 시 반환된 unsubscribe()
함수를 호출하여 구독을 해제할 수 있습니다.
Zustand의 useStore
훅 구현은 내부적으로 useSyncExternalStore
를 활용합니다. 이는 React 18 버전에서 공개된 외부 상태 구독 훅으로, Concurrent Mode가 도입되며 외부 상태 관리 패키지를 사용할 때 티어링 이슈가 발생할 수 있게 되어 이를 보완하고자 만들어진 방식입니다. Zustand와 같은 외부 저장소의 상태를 React 컴포넌트에 안전하게 동기화해줍니다. 덕분에 Zustand를 사용하는 컴포넌트들은 Concurrent Mode에서도 일관성 있게 최신 상태를 얻을 수 있고, 렌더링 도중 상태가 변경되는 문제를 예방합니다. 요약하면, Zustand store는 React 바깥에서 관리되는 외부 상태이지만 useSyncExternalStore
를 통해 React와 깔끔하게 연결되어 동작합니다.
function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any,
) {
const slice = useSyncExternalStore(
api.subscribe,
() => selector(api.getState()),
() => selector(api.getInitialState()),
)
useDebugValue(slice)
return slice
}
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
참고
https://ui.toast.com/posts/ko_20210812
https://ykss.netlify.app/react/flux/?ref=codenary
https://ted-projects.com/react-use-sync-external-store