zustand는 React에서 상태관리를 쉽게 할 수 있도록 도와주는 가벼운 상태 관리 라이브러리이다. Redux와는 다르게 많은 보일러 플레이트를 필요로 하지 않고 간단하고 직관적인 API로 빠르게 상태를 관리할 수 있으며 Immer와 같은 불변성 관리도 자동으로 해준다!
yarn add zustand
redux처럼 zustand도 스토어를 만들어 주는데 여기서 큰 차이점이 있다.
import create from 'zustand';
const initialState = { count: 0}
const actions = {
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
}
const useCountStore = create((set) => ({
...initialState,
...actions,
}));
export default useCountStore;
이게 끝이다. 따로 store를 만들어서 병합해주지 않아도 create를 통해 action과 state를 담아주면 사용 준비 끝!
위의 스토어에서 만든 action들을 바로바로 꺼내서 사용할 수 있다.
// Counter.js
import React from 'react';
import useStore from './store';
const Counter = () => {
const { count, increase, decrease } = useCountStore();
const count = useCountStore(state => state.count);
return (
<div>
<h1>{count}</h1>
<button onClick={increase}>Increase</button>
<button onClick={decrease}>Decrease</button>
</div>
);
};
export default Counter;
다른 라이브러리들과는 다르게 zustand는 아~주 간단하게 구성되어 있다. 주요 기능이 단 두개의 파일로 이루어 져있다.
zustand의 깃허브를 보면 src폴더에 index.ts를 보면
export * from './vanilla.ts'
export * from './react.ts'
정말 단 두개의 파일만 가져오는 것을 볼 수 있다.
type SetStateInternal<T> = {
_(
partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
replace?: false,
): void
_(state: T | { _(state: T): T }['_'], replace: true): void
}['_']
export interface StoreApi<T> {
setState: SetStateInternal<T>
getState: () => T
getInitialState: () => T
subscribe: (listener: (state: T, prevState: T) => void) => () => void
}
type Get<T, K, F> = K extends keyof T ? T[K] : F
export type Mutate<S, Ms> = number extends Ms['length' & keyof Ms]
? S
: Ms extends []
? S
: Ms extends [[infer Mi, infer Ma], ...infer Mrs]
? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
: never
export type StateCreator<
T,
Mis extends [StoreMutatorIdentifier, unknown][] = [],
Mos extends [StoreMutatorIdentifier, unknown][] = [],
U = T,
> = ((
setState: Get<Mutate<StoreApi<T>, Mis>, 'setState', never>,
getState: Get<Mutate<StoreApi<T>, Mis>, 'getState', never>,
store: Mutate<StoreApi<T>, Mis>,
) => U) & { $$storeMutators?: Mos }
// 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>
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>
}
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
}
export const createStore = ((createState) =>
createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
단 98줄로 이루어진 vanilla.ts 파일이다. 타입스크립트 때문에 조금 번잡하게 보이지만 타입선언문 같은 걸 쳐내고 하면 40줄 언저리로 줄어든다. 맨 마지막부터 차근차근 올라가보자.
createStore를 export하는데 이것이 우리가 store를 만들때 사용하는 create일 것이다. createState가 있다면 createStoreImpl의 인자로 집어넣어 주고 아니라면 그냥 실행.
setState
로직은 이전 상태를 입력받아서 이전 상태가 함수형 업데이트라면 함수형 업데이트로 진행하고 아니라면 스테이트 그대로 업데이트한다. Object.is
는 두 객체의 참조형을 비교하는데 다음 값이 현재의 값과 같은지 안같은지 비교하고 같지 않다면 이전 상태를 previousState
에 현재 상태를 저장하고 새로운 상태를 업데이트한다.
만일 replace가 true거나 다음 state가 객체가 아니라면 state
를 nextState
로 대체한다. 그렇지 않으면 nextState
가 객체 일때 현재 상태와 병합해서 업데이트를 진행한다.
마지막으로 상태가 변경되었으니 등록된 listener
에게 새로운 상태와 이전 상태를 전달해서 상태가 변경됨을 알린다.
state를 그대로 반환해준다.
얘도 initialState를 반환한다.
이 함수는 상태 변경을 감지하고 변할때 알릴 수 있도록 리스너를 등록하는 역할을 하고 구독 취소를 할 수 있는 함수 또한 반환하고 있다.
react.ts도 vanilla.ts와 거의 비슷하다.
// import { useDebugValue, useSyncExternalStore } from 'react'
// That doesn't work in ESM, because React libs are CJS only.
// See: https://github.com/pmndrs/valtio/issues/452
// The following is a workaround until ESM is supported.
import ReactExports from 'react'
import { createStore } from './vanilla.ts'
import type {
Mutate,
StateCreator,
StoreApi,
StoreMutatorIdentifier,
} from './vanilla.ts'
const { useDebugValue, useSyncExternalStore } = ReactExports
type ExtractState<S> = S extends { getState: () => infer T } ? T : never
type ReadonlyStoreApi<T> = Pick<
StoreApi<T>,
'getState' | 'getInitialState' | 'subscribe'
>
const identity = <T>(arg: T): T => arg
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
export 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
}
export type UseBoundStore<S extends ReadonlyStoreApi<unknown>> = {
(): ExtractState<S>
<U>(selector: (state: ExtractState<S>) => U): U
} & S
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>>
}
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
여기서 이제 다른점은 react.ts에서는 useSyncExternalStore()
함수를 통해서 selector를 가지고 동작을 한다는 점이 다르다.
이 정도가 zustand의 모든 기능을 설명하고 있다. 라이브러리치고는 상당히 코드가 짧고 이해하기 쉬워보인다. 그래서일까 요즘 zustand의 사용률이 오르고 있다 한다!
이번에 zustand를 배우면서 왜 쓰는지 장점이 어떤지 명확하게 알게 되었다. 이전엔 recoil도 대충 쓰고 했는데 recoil업데이트가 2년전에 멈춰있더라,,, 바로 갖다버리고 zustand를 앞으로 자주 사용하게 될거라는 생각이 들었다. 유용하게 함 써보자!