zustand

  • 상태 관리 라이브러리.
  • 불필요한 리렌더링 방지
  • provider hell 방지

동작 원리

  1. zustand는 발행/구독 모델(pub/sub) 기반으로 이루어져 있으며, 내부적으로 스토어 상태를 클로저로, 상태 변경을 구독할 리스너는 Set을 통해 관리한다.
// vanilla.ts
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 getState: StoreApi<TState>['getState'] = () => state

  const subscribe: StoreApi<TState>['subscribe'] = (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
  1. setState
    상태를 변경하는 setState 함수를 보면 인자가 function 타입일 경우 현재 상태를 인자로 넘겨 nextState를 정의한다. 그리고 nextState와 state가 다르다면 Object.assign 을 이용해서 상태를 갱신해 준다.
  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))
    }
  }
  1. subscribe
    상태를 구독하는 함수를 등록할 때는 subscribe 함수를 사용한다.
const subscribe: StoreApi<TState>['subscribe'] = (listener) => {
  listeners.add(listener);
  // Unsubscribe
  // 구독을 해제하는 함수도 리턴
  return () => listeners.delete(listener);
};


zustand가 React 컴포넌트를 업데이트 할 수 있는 이유

vanilla.ts 를 통해 만들어진 스토어를 기반으로 리액트 컴포넌트에서 사용할 수 있도록 구현되어있다. 이 때 리액트에서 스토어의 값을 불러오기 위해 useSyncExternalStore(zustand v4 이후 버전)를 이용한다.

import { createStore } from './vanilla.ts'

// react.ts
export function useStore<TState, StateSlice>(
  api: ReadonlyStoreApi<TState>,
  selector: (state: TState) => StateSlice = identity as any,
) {
  const slice = React.useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
    () => selector(api.getInitialState()),
  )
  React.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
}


selector를 쓰는 이유

const status = useStore((s) => s.statusByCard[cardKey]);

selector는 store에서 이 컴포넌트가 쓰는 조각만 골라 useSyncExternalStore가 비교할 값으로 넘기는 함수다.

  • 무관한 변경에 의한 리렌더를 막는다. setState는 변경이 있을 때 Object.assign({}, state, next)로 새 객체를 만든다. selector 없이 전체를 구독하면 store 어디가 바뀌든 참조가 바뀌어 모든 구독자가 리렌더된다. selector가 원시값('playing')을 돌려주면 그 값이 같은 동안 Object.is가 true라 건너뛴다.
  • 이 컴포넌트가 store의 어디에 의존하는지 코드에 드러낸다.

조건이 하나 있다. selector가 돌려주는 값이 Object.is로 비교 가능해야 한다. 매 호출마다 새 참조를 만들면 효과가 뒤집힌다.

// 매 호출마다 새 객체 · 새 배열 → 항상 다르다고 판정 → 모든 변경에 리렌더, 심하면 무한 루프 경고
const { a, b } = useStore((s) => ({ a: s.a, b: s.b }));
const visible = useStore((s) => s.items.filter(Boolean));

useSyncExternalStore는 getSnapshot 결과가 캐시돼 있다는 전제로 동작해서, 새 참조가 계속 오면 “The result of getSnapshot should be cached” 경고와 함께 리렌더가 반복된다. 원시값을 따로따로 선택하거나, 객체로 묶어야 하면 useShallow를 붙인다.

useShallow — 얕은 비교

const { a, nested } = useStore(useShallow((s) => ({ a: s.a, nested: s.nested })));
// 비교: Object.is(prev.a, next.a) && Object.is(prev.nested, next.nested)

selector 결과의 1단계 키마다 이전 결과와 Object.is로 비교해 전부 같으면 이전 참조를 그대로 돌려준다. 그래서 uSES가 같은 값으로 보고 리렌더를 건너뛴다.

중첩 객체의 처리:

  • 안의 값이 바뀌어 새 객체가 됐으면({ ...s.nested, x: 1 }) 참조가 달라져 감지된다. 불변 업데이트 관례에서는 이 경로다.
  • 안을 제자리에서 바꿨으면(s.nested.x = 1) 참조가 같아 감지되지 않는다. setState도 참조가 같으면 리스너를 부르지 않으므로, 제자리 변경은 Zustand 모델과 맞지 않는다.
  • 내용은 같은데 참조만 새로 만들어졌으면({ ...s.nested }) 다르다고 보고 리렌더된다. 얕은 비교는 내용이 아니라 키별 참조를 본다.

1단계만 보는 이유: 깊은 비교는 비용이 트리 크기에 비례하고 렌더마다 돈다. 불변 업데이트를 전제하면 “바뀐 곳은 참조가 바뀐다”가 성립해 1단계 비교로 충분하다. 깊은 비교가 필요해 보이면 대개 selector가 매번 새 중첩 객체를 만들고 있다는 신호라, 비교를 깊게 하기보다 selector를 원시값 단위로 쪼갠다.

store 변경의 우선순위

React 18은 state 업데이트마다 “얼마나 급한가”를 정한다. 보통은 업데이트가 일어난 자리가 정한다.

어디서 바꿨나 급한 정도
클릭 · 키 입력 핸들러 안 급함(동기). 바로 그린다
setTimeout · fetch 응답 · 구독 콜백 기본. 곧 그린다
startTransition(() => …) 안 급하지 않음(transition). 이전 화면을 두고 뒤에서 그리며, 급한 일이 오면 멈춘다

uSES로 연결한 store는 이 규칙의 예외다. store가 바뀌어 구독 콜백이 불리면, 그 업데이트는 어디서 일어났든 항상 “급함”으로 처리된다. startTransition 안에서 setState를 불러도 transition이 되지 않는다.

그래서 세 가지가 따라온다.

  1. store 변경은 바로 반영된다. 뒤로 미뤄지지 않는다.
  2. 진행 중이던 transition 렌더(큰 목록을 뒤에서 그리는 중 등)가 있으면 그걸 멈추고 store 변경을 먼저 처리한다.
  3. 같은 틱 안에서 연달아 바꾼 것은 한 번에 그려지지만(React의 기본 batching), 렌더 뒤로 밀려 쌓이는 식으로 묶이지는 않는다.

왜 이렇게 하나. 일관성 때문이다. 외부 store 값이 렌더 도중 바뀌면 한 화면에 두 시점의 값이 섞일 수 있다(tearing). 이를 막으려면 (a) store 변경을 미루지 않고 바로 처리하고, (b) 커밋 직전에 getSnapshot을 한 번 더 읽어 렌더 시작 값과 다르면 그 렌더를 버리고 동기로 다시 그려야 한다. uSES는 이 둘을 한다.

대가. store 변경으로 큰 트리를 다시 그려야 할 때 그 렌더가 동기라서 사용자 입력이 그 뒤로 밀린다. 항목 200개가 store를 구독하고 있으면, store가 한 번 바뀔 때 200개를 다 그릴 때까지 타이핑이 멈춘다. 피하는 방법은 둘이다.

  • 비싼 자식에 넘기는 값을 useDeferredValue로 감싸 그 부분만 늦게 그린다. store 읽기는 동기지만 비싼 렌더는 낮은 우선순위로 내려간다.
  • selector로 구독 범위를 좁혀, store 변경에 반응하는 컴포넌트 수 자체를 줄인다.