Zustand
zustand
- 상태 관리 라이브러리.
- 불필요한 리렌더링 방지
- provider hell 방지
동작 원리
- 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
-
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))
}
}
-
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이 되지 않는다.
그래서 세 가지가 따라온다.
- store 변경은 바로 반영된다. 뒤로 미뤄지지 않는다.
- 진행 중이던 transition 렌더(큰 목록을 뒤에서 그리는 중 등)가 있으면 그걸 멈추고 store 변경을 먼저 처리한다.
- 같은 틱 안에서 연달아 바꾼 것은 한 번에 그려지지만(React의 기본 batching), 렌더 뒤로 밀려 쌓이는 식으로 묶이지는 않는다.
왜 이렇게 하나. 일관성 때문이다. 외부 store 값이 렌더 도중 바뀌면 한 화면에 두 시점의 값이 섞일 수 있다(tearing). 이를 막으려면 (a) store 변경을 미루지 않고 바로 처리하고, (b) 커밋 직전에 getSnapshot을 한 번 더 읽어 렌더 시작 값과 다르면 그 렌더를 버리고 동기로 다시 그려야 한다. uSES는 이 둘을 한다.
대가. store 변경으로 큰 트리를 다시 그려야 할 때 그 렌더가 동기라서 사용자 입력이 그 뒤로 밀린다. 항목 200개가 store를 구독하고 있으면, store가 한 번 바뀔 때 200개를 다 그릴 때까지 타이핑이 멈춘다. 피하는 방법은 둘이다.
- 비싼 자식에 넘기는 값을
useDeferredValue로 감싸 그 부분만 늦게 그린다. store 읽기는 동기지만 비싼 렌더는 낮은 우선순위로 내려간다. - selector로 구독 범위를 좁혀, store 변경에 반응하는 컴포넌트 수 자체를 줄인다.