Zustand는 어떻게 100줄로 React 상태 관리를 해낼까
들어가며
Zustand 코드는 인상 깊어요. 코어 파일에서 코드 라인을 세어보면 vanilla store가 25줄, React binding이 15줄, shallow 비교가 60줄 언저리예요. Redux와는 비교가 안 될 정도로 짧아요.
그럼에도 막상 써보면 부족함이 없어요. 셀렉터로 부분 구독하고 미들웨어를 합성하고 SSR도 안전하게 굴러가요. 이게 어떻게 가능한지 궁금했어요. 그래서 그 해답이 코드에 있지 않을까 하여 들여다보려고 해요.
이 글에서 인용한 코드는 Zustand v5.0.12 기준이니 참고해주세요.
1. vanilla store는 변경을 알리는 신호수
먼저 React를 모르는 vanilla store부터 가보죠. createStoreImpl이 핵심인데 코드가 길지 않아요.
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)
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api as any
}zustand/src/vanilla.ts#L60-L97
state 변수 하나와 listener를 담는 Set 하나, setState/getState/subscribe 세 함수로 구성돼요. 이 구조가 Zustand가 React에 의존하지 않는 이유가 돼요. Node.js에서도 vanilla 환경에서도 같은 코드로 동작해요.
setState에서 눈여겨볼 부분이 있어요.
1. 함수형 업데이트
partial이 함수면 현재 state로 호출해요. useState의 함수형 업데이트와 같은 모양이라 stale closure 걱정 없이 set((s) => ({ count: s.count + 1 }))처럼 쓸 수 있어요.
2. Object.is로 변경 감지
새 state가 기존 state와 같은 reference면 listener 호출을 건너뛰어요. setState 안에서 최적화로 불필요한 호출을 막아주는 가드예요.
3. replace 인자
두 번째 인자 replace가 true거나 nextState가 객체가 아니면 통째로 갈아끼우고 그 외엔 Object.assign으로 합쳐요. 그래서 set({ count: 1 })처럼 일부만 보내도 다른 키가 안 사라져요. Redux의 reducer처럼 매번 전체 state를 반환하지 않아도 되는 이유가 이거예요.
2. React binding은 useSyncExternalStore에 위임
vanilla store만으론 React 컴포넌트가 setState 호출을 알아챌 수 없어요. 그래서 binding이 필요해요.
const identity = <T>(arg: T): T => arg
export function useStore<TState, StateSlice>(
api: ReadonlyStoreApi<TState>,
selector: (state: TState) => StateSlice = identity as any,
) {
const slice = React.useSyncExternalStore(
api.subscribe,
React.useCallback(() => selector(api.getState()), [api, selector]),
React.useCallback(() => selector(api.getInitialState()), [api, selector]),
)
React.useDebugValue(slice)
return slice
}useSyncExternalStore가 다 해줘요. React 18에서 새로 추가된 이 hook은 zustand나 redux처럼 React 바깥에서 관리되는 store를 컴포넌트가 안전하게 읽을 수 있도록 React가 직접 제공하는 hook이에요. 인자 세 개를 받아요.
subscribe(listener)- 스토어 변경을 React에 알리는 채널getSnapshot()- 현재 값을 동기적으로 반환getServerSnapshot()- SSR에서 hydration 비교용 초기 값 반환
Zustand는 vanilla store의 subscribe를 그대로 첫 번째 인자로 넘겨요. 두 번째와 세 번째엔 selector(api.getState())와 selector(api.getInitialState())를 넣어요. selector가 없으면 받은 값을 그대로 돌려주는 함수가 기본값으로 들어가서 state 전체가 그대로 반환돼요.
이 구조 덕분에 Zustand는 Concurrent rendering의 까다로운 문제 두 가지를 자동으로 피해요.
Tearing 방지: 동시에 여러 컴포넌트가 같은 스토어를 읽을 때 중간에 state가 바뀌면 화면이 일관되지 않게 보일 수 있어요. useSyncExternalStore는 한 render pass 안에서 같은 snapshot을 보장하도록 React가 직접 관리해요.
SSR hydration: useSyncExternalStore의 세 번째 인자(getServerSnapshot)는 서버에서 첫 render할 때 보여줄 초기 state를 돌려주는 함수예요. Zustand는 이 자리에 store의 getInitialState를 그대로 연결해요. 덕분에 서버에서 만들어진 첫 state가 클라이언트 hydration 시점에도 똑같이 돌아와 mismatch가 안 생겨요.
이 둘은 사용자가 직접 처리하기 까다로워요. Zustand가 작아진 가장 큰 이유는 React 18 이후 이 책임을 React 코어에 넘겼기 때문이에요. 그 이전엔 같은 일을 하기 위해 Zustand 내부에 따로 구독 큐와 비교 로직을 위한 코드가 있었어요.
3. selector를 안정화하지 않으면 매번 re-render
useStore에 selector를 넘기면 유용한 점이 많아요. 컴포넌트가 필요한 부분만 구독하니 무관한 변경에는 re-render되지 않아요. 하지만 여기에 함정이 하나 있어요.
const { name, email } = useStore((s) => ({ name: s.name, email: s.email }))이 selector는 매번 새 객체를 반환해요. useSyncExternalStore는 내부적으로 Object.is로 이전 snapshot과 비교해서 변경 여부를 판단하는데 새 객체는 매번 다른 reference라 비교에 실패해요. 그래서 store 어디가 바뀌든 이 컴포넌트는 매 render마다 다시 render돼요.
해결책이 두 가지예요. selector 안에서 새 객체를 만들지 말고 값 하나만 가져오거나 (useStore((s) => s.name)) 새 객체를 반환하더라도 reference 대신 속성 값을 비교해주는 도구를 함께 쓰거나요. 후자가 useShallow예요.
4. useShallow는 ref 한 줄로 selector를 안정화해요
import React from 'react'
import { shallow } from '../vanilla/shallow.ts'
export function useShallow<S, U>(selector: (state: S) => U): (state: S) => U {
const prev = React.useRef<U>(undefined)
return (state) => {
const next = selector(state)
return shallow(prev.current, next)
? (prev.current as U)
: (prev.current = next)
}
}zustand/src/react/shallow.ts#L4-L12
useRef로 이전 결과를 들고 있다가 새 결과와 shallow하게 같으면 이전 ref를 그대로 반환해요. useSyncExternalStore 입장에선 이전과 똑같은 reference라 Object.is가 통과하고 re-render가 일어나지 않아요. 다르면 ref를 갱신하면서 새 값을 반환해요.
ref 하나로 reference가 바뀌지 않게 유지하는 코드인데 이 hook이 의미를 가지려면 shallow 비교가 빠르고 정확해야 해요. shallow 함수는 따로 분리돼 있어요.
export function shallow<T>(valueA: T, valueB: T): boolean {
if (Object.is(valueA, valueB)) {
return true
}
if (
typeof valueA !== 'object' ||
valueA === null ||
typeof valueB !== 'object' ||
valueB === null
) {
return false
}
if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) {
return false
}
if (isIterable(valueA) && isIterable(valueB)) {
if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) {
return compareEntries(valueA, valueB)
}
return compareIterables(valueA, valueB)
}
return compareEntries(
{ entries: () => Object.entries(valueA) },
{ entries: () => Object.entries(valueB) },
)
}zustand/src/vanilla/shallow.ts#L48-L74
위에서부터 early return해요. 같은 reference면 통과, 둘 중 하나라도 객체가 아니면 실패, 프로토타입이 다르면 실패로 처리해요. isIterable 분기로 Map이나 Set 같은 iterable은 별도로 처리해요. entries()가 있는 타입은 key-value 비교, 그 외는 순서대로 element를 비교해요. 일반 객체는 entries()가 없으니 Object.entries로 [key, value] 쌍을 만들어 감싼 다음 Map/Set을 비교할 때 쓴 compareEntries로 넘겨요.
React의 useMemo deps 비교나 React-Redux의 shallowEqual보다 검사 단계가 더 많아요. Map/Set을 props로 다루는 경우까지 고려한 결과예요.
5. create는 hook과 store를 한 객체로
마지막으로 create 함수예요. 사용자가 가장 자주 쓰는 진입점이고 이것도 짧아요.
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState)
const useBoundStore: any = (selector?: any) => useStore(api, selector)
Object.assign(useBoundStore, api)
return useBoundStore
}
export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create세 단계로 동작해요.
- vanilla
createStore로 api를 만들어요 - selector를 받아
useStore(api, selector)를 호출하는 함수useBoundStore를 만들어요 Object.assign(useBoundStore, api)로 함수에 setState/getState/subscribe를 붙여요
세 번째 덕분에 useBoundStore는 hook이면서 동시에 store API 객체예요. 그래서 컴포넌트 안에선 const name = useBoundStore((s) => s.name)처럼 hook으로 쓰고 컴포넌트 밖에선 useBoundStore.getState()나 useBoundStore.subscribe(...)처럼 객체로 써요. 이벤트 핸들러나 utility 함수에서 React 컨텍스트 없이 store를 다룰 수 있는 이유예요.
JavaScript 함수가 일급 객체라 가능한 패턴이에요.
마무리
Zustand 코어를 정리할게요.
- vanilla store: state 한 개와 listener Set 한 개로 만든 구독 구조
- React binding: tearing과 SSR 처리는
useSyncExternalStore에 위임 - useShallow: ref 한 줄로 selector 결과의 reference가 바뀌지 않게 유지
- create: 함수에 store API를 부착해 hook과 객체를 동시에 노출
이렇게 코드가 작은 이유는 React 18의 useSyncExternalStore가 까다로운 책임을 받아갔기 때문이에요. tearing 방지와 SSR snapshot은 Zustand가 직접 풀려면 무거운 코드가 필요했을 텐데 React 코어에 위임하면서 라이브러리가 본연의 일에만 집중할 수 있게 됐어요.
그래서 코드를 읽고 나서 'Zustand가 어떻게 작동하는가'보다 '외부 스토어를 React와 연결하는 적절한 방법이 무엇인가'의 관점으로 바라보게 됐어요. Zustand는 그 패턴을 가장 적은 코드로 구현한 사례이고요. 이런 패턴으로 Jotai, Valtio, Redux Toolkit의 useSelector 같은 다른 외부 스토어 라이브러리도 React에 비슷한 방식으로 연결돼요.
