Zustand는 크게 3 가지로 분석해볼 수 있다.
1. state 관리를 위한 vaniila.ts와 react.ts
2. mutate를 위한 middleware
3. 최적화를 위한 shallow
이번 섹션에서는 1번인 상태 관리 코드를 우선적으로 살펴보고자 한다.
상태 관리 코드는 2가지 파일에 나눠서 관리 중이다.
첫 번째는 vanilla.ts 파일이고, 두 번째는 react.ts 파일이다.
react.ts 코드는 JS로 관리하는 상태관리 코드를 `useSyncExternalStore`라는 훅을 통해 리액트의 고유 상태로 넣어주는 코드이다.
때문에, vanilla.ts 파일을 자세히 살펴보면 react.ts 파일을 해석하는 데 오래 걸리지 않는다.
`vanilla.ts`
이 파일을 살펴보면 크게 2가지 함수가 존재함을 확인할 수 있다.
먼저 `createStoreImpl` 함수이며, 이 함수는 store 생성 함수의 구현부이다.
// 타입 정의
type CreateStoreImpl = <
T,
Mos extends [StoreMutatorIdentifier, unknown][] = [],
>(
initializer: StateCreator<T, [], Mos>, // 상태 생성 함수
) => Mutate<StoreApi<T>, Mos>;
// 함수
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) => {
// TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved
// https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
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
}
이 함수는 총 4개의 속성을 가진 객체를 반환한다.
setState는 상태를 변경해주는 함수, getState는 상태를 가져오는 함수, getInitialState는 초깃값을 가져오는 함수, subscribe는 상태를 구독할 수 있도록 해주는 함수다.
이 때, getInitialState는 SSR을 위해서 사용되는 함수이다. (`useSyncExternalStore`의 세번째 인자값에 사용된다.)
그 다음으로 vanilla.ts는 createStore라는 함수를 정의했다.
// 타입 정의 (함수 시그니처)
// CreateStore 타입은 Store를 생성하는 함수의 형태를 정의합니다.
// 상태 생성자를 인자로 받아 Mutate된 StoreApi를 반환합니다.
type CreateStore = {
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>, // 상태 생성 함수
): Mutate<StoreApi<T>, Mos>;
<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>, // 상태 생성 함수를 나중에 인자로 받는 경우
) => Mutate<StoreApi<T>, Mos>;
};
// 함수 정의
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
이 함수는 매개변수가 없으면 createStoreImpl 함수를 그대로 반환하고, 매개변수로 createState 값이 있으면 createStoreImpl를 호출한 객체값을 반환받는다.
그렇다면 createStore에서 매개변수로 받게 되는 StateCreator 타입은 무엇일까? 그리고, Mutate 타입은 무엇일까?
먼저 Mutate 타입이다.
// Mutate 타입은 StoreMutators를 기반으로 상태를 변형하는 역할을 합니다.
// 다양한 StoreMutators의 길이에 따라 상태 변형 로직을 적용합니다.
export type Mutate<S, Ms> =
// Ms가 동적 배열이면 상태 S 그대로 반환
number extends Ms['length' & keyof Ms]
? S
: // Ms가 빈 배열이면 상태 S 그대로 반환
Ms extends []
? S
: // Ms가 [[Mi, Ma], ...Mrs] 형태의 배열이면 변형을 적용 (재귀적으로 Mutate 호출)
Ms extends [[infer Mi, infer Ma], ...infer Mrs]
? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
: // 위의 모든 조건이 해당되지 않으면 never 반환
never;
// StoreMutators는 상태와 Arguments을 인자로 받아 상태를 변형하는 로직을 정의하는 타입입니다.
// 기본적으로 빈 타입이지만, 커스텀하게 확장될 수 있습니다.
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-object-type
export interface StoreMutators<S, A> {}
export type StoreMutatorIdentifier = keyof StoreMutators<unknown, unknown>; // StoreMutators의 키를 사용하여 식별자 정의
`Mutate` 타입은 `Mutator`(미들웨어)를 적용할 때의 상태 타입을 유추할 수 있도록 해준다.
동적 배열인 경우 `S` 그대로 반환하고, 빈 배열(미들웨어 적용 안한 경우)도 `S`, 정적 배열인 경우 재귀적으로 `Mutate`를 적용하여 타입을 유추하고, 그외의 경우 never로 타입을 결정한다.
Mi: 각 mutator의 식별자 (Mutator identifier)
Ma: mutator에 전달될 인자 (Mutator arguments)
Mrs: 나머지 mutator들의 배열 (Mutators rest)
이번엔 `StateCreator` 타입이다.
export type StateCreator<
T,
Mis extends [StoreMutatorIdentifier, unknown][] = [],
Mos extends [StoreMutatorIdentifier, unknown][] = [],
U = T,
> = ((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>, // setState 함수
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>, // getState 함수
store: Mutate<StoreApi<T>, Mis>, // store 전체
) => U) & { $$storeMutators?: Mos };
Mis와 Mos는 각각 Mutators in과 상태 생성자 이후에 적용되는 mutators와 상태 생성자의 입력에 영향을 주는 mutators와 상태 생성자 이후에 적용되는 mutators를 나타낸다.
즉, 간단하게 설명하면 다음과 같다.
- Mis: 상태 생성자가 사용할 때 `setState`, `getState` 등을 변형함. (상태를 생성하거나 업데이트 할 때)
- Mos: 상태 생성자가 반환한 상태에 추가적으로 변형을 적용함. (상태 생성자가 반환한 후 추가적인 기능을 적용하거나 상태를 결합)
Zustand에서 mutators는 상태 업데이트나 조회 동작을 커스터마이징하거나, 상태에 특정 기능을 추가하는 데 사용된다.
Mis와 Mos와 연관된 대표적인 mutator들을 각각 몇 가지 예시로 설명해보자면 다음과 같다.
Mis에 적용할 수 있는 Mutator들
devtools
- 역할: 상태 관리에 DevTools를 연결하여 개발 중 상태 변화를 시각적으로 추적할 수 있게 해준다.
- 동작: setState, getState를 감싸서 상태 변화가 일어날 때마다 DevTools에 그 상태를 기록한다.
immer
- 역할: setState에서 Immer 라이브러리를 사용하여 불변성을 신경 쓰지 않고 상태를 직접 수정할 수 있게 해준다.
- 동작: setState 함수를 감싸서 내부적으로 Immer를 사용하여 상태의 불변성을 유지하면서도 직관적인 상태 수정이 가능하게 한다.
persist
- 역할: 상태를 localStorage나 sessionStorage에 자동으로 저장하고, 다시 로드할 수 있게 해준다.
- 동작: setState와 getState를 감싸서 상태가 변경될 때마다 이를 지정된 저장소에 저장하고, 애플리케이션이 다시 로드될 때 해당 저장소에서 상태를 가져온다.
Mos에 적용할 수 있는 Mutator들
combine
- 역할: 여러 상태를 하나로 결합하여 관리할 수 있게 해준다.
- 동작: 상태 생성자가 반환하는 객체를 변형하여 여러 상태를 하나의 스토어로 결합할 수 있다.
subscribeWithSelector
- 역할: 상태의 특정 부분에 대해서만 선택적으로 구독할 수 있는 기능을 제공한다.
- 동작: 반환된 상태에서 특정 부분을 구독하고, 해당 부분이 변경될 때만 리렌더링 등의 동작을 발생시킨다.
이러한 mutator들을 사용하면 상태의 관리와 업데이트를 더욱 유연하게 제어할 수 있다.
Mis는 상태 생성 시점에서 API 함수들을 변형하는 데 쓰이고, Mos는 상태가 반환된 후 추가적으로 변형하는 데 사용된다.
`react.ts`
이 함수에는 3가지 함수를 정의해 두었는데 가장 중요한 부분은 `useStore` 훅이다.
// 타입 정의
export function useStore<S extends ReadonlyStoreApi<unknown>>(
api: S,
): ExtractState<S>;
export function useStore<S extends ReadonlyStoreApi<unknown>, U>(
api: S,
selector: (state: ExtractState<S>) => U,
): U;
// 함수
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,
() => selector(api.getState()),
() => selector(api.getInitialState()),
);
React.useDebugValue(slice);
return slice;
}
이 훅은 useSyncExternalStore를 활용하여 외부의 상태를 리액트의 상태로 연동시키도록 도와준다.
매개변수는 총 2가지이다.
1. `api`는 상태 관리 API이다. 이 API는 `getState`, `subscribe`, 그리고 `getInitialState` 메서드를 속성으로 제공할 수 있어야 한다.
2. `selector`는 `TState`에서 필요한 부분만 추출하는 함수다. 기본적으로 `identity`로 지정되어 있는데, 이는 상태의 전체를 반환하는 기본 선택자다. 즉, 사용자가 선택자를 제공하지 않으면 기본적으로 상태 전체를 구독하게 된다.
최종적으로 선택된 상태 조각을 반환합니다. 컴포넌트는 이 상태를 사용하여 필요한 부분만 구독하고 렌더링할 수 있습니다.
그 다음에 확인할 함수는 `createImpl` 함수이다.
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
const api = createStore(createState);
const useBoundStore: any = (selector?: any) => useStore(api, selector);
Object.assign(useBoundStore, api);
return useBoundStore;
};
1. createState
- createImpl는 상태를 만드는 함수를 받는다.
이 함수는 StateCreator 타입으로, `setState`, `getState`, `store`를 매개변수로 받는 함수이다.
2. createStore(createState)
- `createStore`는 `createState`를 사용하여 상태를 초기화하고, 상태 관리 API를 생성하는 함수이다.
이 API는 `getState`, `subscribe`, `setState` 등의 메서드를 포함한다.
3. useBoundStore
- 이 변수는 `useStore` 훅을 래핑한 함수며, `selector`를 인자로 받아 `useStore`에서 상태를 구독하고, 선택된 상태 조각을 반환한다.
- `useBoundStore`는 상태 API(api)와 `useStore` 훅을 결합한 형태로, 선택자 함수가 없을 경우 기본적으로 전체 상태를 구독하게 됩니다.
4. Object.assign(useBoundStore, api)
- `useBoundStore` 함수에 `api` 객체의 모든 메서드를 복사하여 추가한다. 이렇게 하면 `useBoundStore` 자체가 상태 관리 API의 메서드를 모두 사용할 수 있게 된다.
예를 들어, `useBoundStore.getState()`나 `useBoundStore.setState()` 같은 API 호출이 가능해진다.
5. return useBoundStore
- 최종적으로 useBoundStore를 반환한다. 이 함수는 상태를 구독하고 사용할 수 있을 뿐만 아니라 상태 관리 API의 모든 메서드를 함께 제공하는 상태 관리 훅이 된다.
마지막으로 create 함수는 그냥 vanilla.ts의 createStore와 유사하다.
type Create = {
<T, Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
): UseBoundStore<Mutate<StoreApi<T>, Mos>>;
<T>(): <Mos extends [StoreMutatorIdentifier, unknown][] = []>(
initializer: StateCreator<T, [], Mos>,
) => UseBoundStore<Mutate<StoreApi<T>, Mos>>;
};
export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
createState ? createImpl(createState) : createImpl) as Create;
이렇게 zustand가 `useState`와 같은 리액트 훅을 사용하지 않고 어떻게 리액트 내부에서 전역 상태관리를 할 수 있는지 대략적으로 확인해보았다. 아직 미들웨어를 확인해보지 못해서 Mutator 관련된 타입들을 정확히 이해하지 못했는데, 다음엔 미들웨어를 확인해보면서 좀 더 이해를 해봐야겠다.