Zustand는 어떻게 구현되어 있을까(v4.5.2)

bluejoy·2024년 3월 11일
2

React

목록 보기
17/19

개론

예제

import { create } from "zustand";

interface IStore {
  value: number;
  setValue: (value: number) => void;
}
const useStore = create<IStore>((set) => ({
  value: 0,
  setValue: (value: number) => set({ value }),
}));
export const App = () => {
  const value = useStore((state) => state.value);
  const setValue = useStore((state) => state.setValue);
  const handleValueUpdate = () => {
    setValue(value + 1);
  };
  return (
    <div>
      <h1>Hello Value: {value}!!</h1>
      <button onClick={handleValueUpdate}>+1</button>
    </div>
  );
};

버튼을 누르면 숫자가 증가하는 매우 쉬운 예제

그러나 이 예제는 이렇게도 바꿀 수 있다.

import { create } from "zustand";

interface IStore {
  value: number;
  setValue: (value: number) => void;
}
const useStore = create<IStore>((set) => ({
  value: 0,
  setValue: (value: number) => set({ value }),
}));

const handleValueUpdate = () => {
  useStore.setState((state) => ({ value: state.value + 1 }));
};
export const App = () => {
  const value = useStore((state) => state.value);
  return (
    <div>
      <h1>Hello Value: {value}!!</h1>
      <button onClick={handleValueUpdate}>+1</button>
    </div>
  );
};

handleValueUpdate는 컴포넌트 외부에 있고, useStore.setState는 훅을 사용하지 않는 것으로 보인다. 이들은 어떻게 반응성을 구현했을까?

Zustand 코드 분석

버전 4.5.2를 대상으로 한다.

zustand는 코드의 길이가 매우 짧은 편에 속하지만 타입스크립트를 정말 알차게 사용해서 이해가 어려웠다.

공식 문서 의외의 글을 거의 참조하지 않아서, 잘못되거나 다른 해석이 존재할 수 있습니다.

사전 지식

중복해서 나오는 타입이나 개념에 대해서 미리 정리하자.

SetStateInternal

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L1

type SetStateInternal<T> = {
  _(
    partial: T | Partial<T> | { _(state: T): T | Partial<T> }['_'],
    replace?: boolean | undefined,
  ): void
}['_']

예시에서 나오는 useStore.setState의 타입이다. zustand에서는 Generic T를 항상 storestate로 취급한다.

매우 복잡해 보이지만 간단하게 정리하면 다음과 같다.

type SetStateInternalSimple<T> = {
  partial: T | Partial<T> | ((state: T) => T | Partial<T>);
};

이렇게 간편하게 선언할 수 있는 것을 Object 내부에 _으로 선언하고 해당 속성을 지정해서 정의했을까?

https://blog.axlight.com/posts/why-zustand-typescript-implementation-is-so-ugly/
제작자의 블로그에 나온 이유

// 제작자 블로그의 쉬운 예제
type Test1<T> = {
  _(a: T): void;
}["_"];

type Test2<T> = (a: T) => void;

type State = { name: string; age: number };

declare function run<Fn extends Test1<unknown>>(fn: Fn): void;
declare function run2<Fn extends Test2<unknown>>(fn: Fn): void;
declare const setState: Test1<State>;
declare const setState2: Test2<State>;
run(setState);
// 런타임 에러 발생
run2(setState2);

타입스크립트의 로직에 대한 일종의 우회로 보인다.

아무튼 SetStateInternal<T>store의 새로운 state 일부 혹은 전체를 받거나 함수형 업데이트를 지원하는 타입이다.

// 구현체는 이런 식으로 사용 가능
useStore.setState({
	value: 0
});
useStore.setState((prev)=>({
	value: prev - 1
}));

StoreApi

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L8C1-L17C2

export interface StoreApi<T> {
  setState: SetStateInternal<T>
  getState: () => T
  getInitialState: () => T
  subscribe: (listener: (state: T, prevState: T) => void) => () => void
  /**
   * @deprecated Use `unsubscribe` returned by `subscribe`
   */
  destroy: () => void
}

StoreApicreate의 결과로 리턴되는 store 조작 메서드에 대한 타입이다.

subscribe은 리스너 함수를 파라미터로 받고, unsubscribe 함수를 리턴한다. destroy도 존재하지만 deprecated 상태이다.

Get

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L19

type Get<T, K, F> = K extends keyof T ? T[K] : F

KT에 존재할 경우 T[K]가 되고 없을 경우 F가 되는 조건부 타입이다.

더 자세히 설명하자면 GetGeneric으로 3개의 타입이 주어진다. K extends keyof T은 조건부 타입을 정의한다. T 타입의 key들 중 하나에 K가 할당할 수 있다면 T[K]이고 아니라면 F이다.

아래 예제를 참조하자!!

type A = {
  "1": 2;
  2: 4;
};
// B는 "1" | 2 이다.
type B = keyof A;
// 2는 B에 할당될 수 있기에 "yes"라는 리터럴 타입이다.
type C = 2 extends B ? "yes" : "no";

여기까진 그래도 이해할만 하다!!

Mutate 타입

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L21C1-L27C14

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
  • Ms: middlewares이다.

첫번째 조건문을 보자

number extends Ms['length' & keyof Ms]Mslength 속성이 number인지 체크한다. 즉 arrayLike인지 체크하기 위한 것이다.

그런데 단순히 Ms['length']로 체크하지 않고 복잡하게 접근하는 이유는 무엇일까?

type nonArray = {
  _: number;
};
// error 
type B = nonArray["length"];

여기서 B의 타입은 any이다. 타입스크립트에서는 알 수 없는 객체의 구조를 단언하지 않기에 그렇다.

https://www.typescriptlang.org/docs/handbook/type-checking-javascript-files.html#unspecified-type-parameters-default-to-any 참조!!

그러므로 아래와 같은 경우에 문제가 발생한다.

type nonArray = {
  _: number;
};
type originalCheck<T> = number extends T["length" & keyof T] ? true : false;
// 명시되지 않은 속성은 any로 취급함. length가 없다면 number는 any에 할당 가능하므로 true
type errorCheck<T> = number extends T["length"] ? true : false;
// [false, true]
type checks = [
  originalCheck<nonArray>,
  errorCheck<nonArray>,
];

그렇다면 왜 arrayLike를 통해 검증하지 않을까?

아래 예제를 보면 명확하게 확인 가능하다.

type arrayLike = {
  length: number;
};
type tuple = [number, number];
type nonArray = {
  _: number;
};
// tuple은 거름
type originalCheck<T> = number extends T["length" & keyof T] ? true : false;
type errorCheck<T> = number extends T["length"] ? true : false;
// tuple도 포함
type otherCheck<T> = T extends ArrayLike<any> ? true : false;

// [true, false, false, true, false, true, true, true, false]
type checks = [
  originalCheck<arrayLike>,
  originalCheck<tuple>,
  originalCheck<nonArray>,
  errorCheck<arrayLike>,
  errorCheck<tuple>,
  errorCheck<nonArray>,
  otherCheck<arrayLike>,
  otherCheck<tuple>,
  otherCheck<nonArray>,
];

아하 길이가 정해진 배열(튜플)에 대해서는 다른 곳에서 취급하기 위해서 였다.

그럼 두번째 조건문의 분석은 쉬워진다.

// 정확하게는 length만 정의되어도 되므로 유사 배열은 아니지만 비슷하게 보자~
export type Mutate<S, Ms> = 
	number extends Ms['length' & keyof Ms] // 튜플이 아닌 유사 배열인가?
  ? S
  : Ms extends [] // Ms가 빈 튜플인가?
    ? S
    : Ms extends [[infer Mi, infer Ma], ...infer Mrs] // Ms는 반드시 이런 형식
      ? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
      : never

슬슬 감이 온다.

두번째 조건문부터 세번째 조건문은 재귀적으로 타입을 정의하고 있다!!(Ms extends []가 종료 조건)

Ms extends [[infer Mi, infer Ma], ...infer Mrs] // Ms는 반드시 이런 형식
  ? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
  : never

Ms의 첫번째 요소의 첫번째 요소를 Mi로, 두번째 요소를 Ma로 참조한다.

  • Mi: 확장된 StoreMutatorIdentifier, StoreMutator를 식별하기 위한 키이다(미들웨어의 이름).
  • Ma: redux 미들웨어에서는 action 관련 제네릭을 추가해주기 위해 사용한다. Middleware Argument 같은 약자 아닐까 싶다. 즉 추가적인 타입 정보를 제공해주는 역활일듯?

MiMa 그리고 S를 이용해서 새로운 타입(StoreMutators)을 정의하고, 그 타입을 다시금 MutateS로 넣어주고 튜플의 나머지 요소를 Ms로 넣어준다.

StoreMutators 타입, StoreMutatorIdentifier 타입

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L41C1-L43C1

export interface StoreMutators<S, A> {}
export type StoreMutatorIdentifier = keyof StoreMutators<unknown, unknown>

텅텅 비어있다. 왜 그럴까?

https://github.com/pmndrs/zustand/blob/v4.5.2/src/middleware/immer.ts#L13C1-L19C1

declare module '../vanilla' {
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  interface StoreMutators<S, A> {
    ['zustand/immer']: WithImmer<S>
  }
}

바로 미들웨어에서 사용 예시를 볼 수 있는데, StoreMutators의 타입을 확장시켜 원하는 대로 S를 이용해 store의 메서드를 확장시킬 수 있다. 여기서 정황상 Sstore의 타입(StoreApi 혹은 확장된)인 것을 유추 가능하다.

다시 Mutate 타입

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L21C1-L27C14

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

Mutate에 대해 정리해보겠다.

  • Mutate는 2가지 제네릭을 받는다. Sstore의 타입(StoreApi), Ms는 포함할 middleware들의 타입이다.
  • Ms가 튜플이 아니라 유사 배열이라면 S를 그대로 사용
  • Ms가 빈 튜플이라면 S를 그대로 사용
  • Ms가 만약 존재한다면 반드시 [Mi, Ma] 형태이고 MiStoreMutatorIdentifier, Ma는 제네릭을 통해 추가적인 타입 정보를 제공한다.

결론적으로 이 타입은 StoreApi를 받아 Ms(미들웨어들의 정보)를 통해 재귀적으로 변환을 수행하는 타입이다.

React 기타 타입들

https://github.com/pmndrs/zustand/blob/v4.5.2/src/react.ts#L21C1-L29C1

type ExtractState<S> = S extends { getState: () => infer T } ? T : never

type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'subscribe'>

type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {
  /** @deprecated please use api.getInitialState() */
  getServerState?: () => ExtractState<S>
}

ExtractStateS(store) 타입으로 부터 getState 메서드의 리턴 값을 가져온다. 즉 storestate를 가져오는 유틸리티 타입이다.

ReadonlyStoreApiT(state) 타입으로부터 StoreApi를 생성 후 write method를 제외해서 반환한다.

WithReact는 현재로써 deprecated된 속성만을 포함하고 있으므로 사실상 아무런 의미가 없는 래퍼이다.

여기까지가 기본 지식.

이제 실제 코드 분석한다.

create 함수

우선 스토어를 만드는 create 함수를 먼저 파악해보자.

진입점에는 아무런 내용이 없다.

https://github.com/pmndrs/zustand/blob/v4.5.2/src/index.ts

export * from './vanilla.ts'
export * from './react.ts'
export { default } from './react.ts'

vanilla.tscreate 함수가 없으므로 넘어간다.

create를 금방 발견했다!!

https://github.com/pmndrs/zustand/blob/v4.5.2/src/react.ts#L124C14-L124C20

export const create = (<T>(createState: StateCreator<T, [], []> | undefined) =>
  createState ? createImpl(createState) : createImpl) as Create

이것만 봐서는 이해가 힘드므로 Create, StateCreator, createImpl 도 살펴봐야겠다.

우선 createStatenullable이고 조건이 존재하는 이유는 사용시에 다음과 같은 2가지 사용 사례가 있기 때문이다. 이게 2가지로 나뉘게 된 이유에 대해서는 https://docs.pmnd.rs/zustand/guides/typescript을 참조하면 좋을 듯 하다.

const useStore = create((set) => ({
  bears: 0,
})) // createImpl(createState)을 호출
const useStore = create()((set) => ({
  bears: 0,
})) // 반환된 createImpl을 다시 호출

아래 create 타입을 살펴보자.

Create 타입

https://github.com/pmndrs/zustand/blob/v4.5.2/src/react.ts#L91

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>>
  /**
   * @deprecated Use `useStore` hook to bind store
   */
  <S extends StoreApi<unknown>>(store: S): UseBoundStore<S>
}

Create 타입은 함수 오버로딩을 통해 구현되어 있다. 3번째는 무시하고 1번째와 2번째만 보자면 결국

(initializer: StateCreator<T, [], Mos>) => UseBoundStore<Mutate<StoreApi<T>, Mos>>

위의 사용에서 보이듯이 이 함수 자체이거나 이 함수를 반환하는 고차 함수이다.

나머지에 대해서는 뒤에서 다루겠다.

StateCreator 타입

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L41C1-L43C1

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 }

이제 뭔가 감이 온다.

Tstate, Mis는 이 create의 내부에 위치한 미들웨어들의 타입 확장 정보들이다.

// 참고
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'

interface BearState {
  bears: number
  increase: (by: number) => void
}

const useBearStore = create<BearState>()(
  devtools(
    persist(
      (set, get) => ({
        bears: 0,
        increase: (by) => set((state) => ({ bears: state.bears + by })),
      }),
      { name: 'bearStore' },
    ),
  ),
)

Mos는 외부에 초기화 메서드가 위치한 미들웨어들의 타입 확장 정보!! U는 리턴 타입인데 기본적으로는 T이다. 그러나 리턴 값을 확장하는 미들웨어가 있다면 달라질 것이다.

$$storeMutators는 아무래도 서드파티 라이브러리를 위한 외부 미들웨어의 정보를 제공해주는 듯 혹은 그냥 에러 나지 말라고 사용하는 것일 수도 있음.

StateCreator는 말 그대로 set,get,store 3가지 파라미터를 받아 state를 생성해주는 함수이다. 여기서 이 set,get,store 3가지는 StoreApi와 동일하다.

UseBoundStore 타입

https://github.com/pmndrs/zustand/blob/v4.5.2/src/react.ts#L79C1-L89C6

export type UseBoundStore<S extends WithReact<ReadonlyStoreApi<unknown>>> = {
  (): ExtractState<S>
  <U>(selector: (state: ExtractState<S>) => U): U
  /**
   * @deprecated Use `createWithEqualityFn` from 'zustand/traditional'
   */
  <U>(
    selector: (state: ExtractState<S>) => U,
    equalityFn: (a: U, b: U) => boolean,
  ): U
} & S

기초 지식에서 S extends WithReact<ReadonlyStoreApi<unknown>>가 그냥 최소한 storeread-only 메서드들만을 포함한 것을 확인했다.

UseBoundStore는 2가지 함수가 오버로딩 되어있다.

  • (): ExtractState<S>: StoreState를 리턴하는 함수
  • <U>(selector: (state: ExtractState<S>) => U): U: selector를 통해 state를 획득하는 함수

UseBoundStore는 바로 그 useStore의 리턴 타입이다!!

const useStore = create<IStore>()((set) => ({
  value: 0,
  setValue: (value: number) => set({ value }),
}));
export const App = () => {
	// 2가지가 오버로딩 되어있으니.
  const value = useStore((state) => state.value);
  const value2 = useStore().value;
  // ...
}

다시 Create 타입

(initializer: StateCreator<T, [], Mos>) => UseBoundStore<Mutate<StoreApi<T>, Mos>>

그럼 이 타입에은 바깥에서 받아온 외부 미들웨어의 정보(Mos)를 포함한 StateCreatorinitializerUseBoundStore<Mutate<StoreApi<T>, Mos>>를 정의한다.

여기서 initializer: StateCreator의 제네릭에 <T, [], Mos>이 들어간 이유는 create의 외부에는 미들웨어가 위치하지 않기 때문이다. 모든 미들웨어는 이 initializer를 가지고 확장 후 변환된 StateCreator를 리턴한다.

UseBoundStore의 제네릭에 Mutate<StoreApi<T>, Mos>이 들어간 이유는 최종적인 store결과물의 타입의 형태는 외부 미들웨어들(Mos)의 변환을 적용시킨 StoreApi이기 때문이다.

https://github.com/pmndrs/zustand/blob/v4.5.2/src/middleware/immer.ts#L5C1-L11C62
immer의 타입과 사용 예시

type Immer = <
  T,
  Mps extends [StoreMutatorIdentifier, unknown][] = [],
  Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
  initializer: StateCreator<T, [...Mps, ['zustand/immer', never]], Mcs>,
) => StateCreator<T, Mps, [['zustand/immer', never], ...Mcs]>

// 예시
const useStore = create<IStore>()(
  immer((set) => ({
    value: 0,
    setValue: (value: number) => set({ value }),
  })),
);

createImpl 함수

https://github.com/pmndrs/zustand/blob/v4.5.2/src/react.ts#L104

const createImpl = <T>(createState: StateCreator<T, [], []>) => {
  if (
    import.meta.env?.MODE !== 'production' &&
    typeof createState !== 'function'
  ) {
    console.warn(
      "[DEPRECATED] Passing a vanilla store will be unsupported in a future version. Instead use `import { useStore } from 'zustand'`.",
    )
  }
  const api =
    typeof createState === 'function' ? createStore(createState) : createState

  const useBoundStore: any = (selector?: any, equalityFn?: any) =>
    useStore(api, selector, equalityFn)

  Object.assign(useBoundStore, api)

  return useBoundStore
}

파라미터 createState의 타입이 함수가 아닌 경우(직접 만든 store를 넘기는 경우?)는 현재 deprecated 됐다.

createStore로 따라가보자.

createStore

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L44C1-L52C2
https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L109

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

파라미터 createStateCreate 타입과 마찬가지로 2가지 형태로 오버로딩 되어 있다. 그러나 일반적으로는 createStatenull인 경우가 없다. 현재는 deprecatedcontext 관련 코드에서 그 흔적을 찾아볼 수 있다.

https://github.com/pmndrs/zustand/blob/v4.5.2/src/context.ts#L58
흔적

여담이지만 v5에서도 저 코드가 남아있는데, 이유를 도저히 모르겠어서 PR에 질문을 남겼다…

https://github.com/pmndrs/zustand/pull/2138#issuecomment-1987342446
미들웨어 타입스크립트를 위한 코드라고 한다.

createStoreImpl

https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L54C1-L59C30
https://github.com/pmndrs/zustand/blob/v4.5.2/src/vanilla.ts#L61C1-L107C2

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 destroy: StoreApi<TState>['destroy'] = () => {
    if (import.meta.env?.MODE !== 'production') {
      console.warn(
        '[DEPRECATED] The `destroy` method will be unsupported in a future version. Instead use unsubscribe function returned by subscribe. Everything will be garbage-collected if store is garbage-collected.',
      )
    }
    listeners.clear()
  }

  const api = { setState, getState, getInitialState, subscribe, destroy }
  const initialState = (state = createState(setState, getState, api))
  return api as any
}

인라인 타입인 TState를 정의해 state에 대한 구체적인 정보 없이도 api의 타입을 결정 지을 수 있도록 해준다. setState는 주소 값 기준으로 비교 후 다르다면 state를 갱신하고 갱신을 전파한다. ListenerSet로 구현되어 subscribe 시 저장되며, 전파된다.

생각보다 핵심 구현부가 간결해서 신기했다.

useStore

https://github.com/pmndrs/zustand/blob/v4.5.2/src/react.ts#L34C1-L77C2

export function useStore<S extends WithReact<StoreApi<unknown>>>(
  api: S,
): ExtractState<S>

export function useStore<S extends WithReact<StoreApi<unknown>>, U>(
  api: S,
  selector: (state: ExtractState<S>) => U,
): U

/**
 * @deprecated The usage with three arguments is deprecated. Use `useStoreWithEqualityFn` from 'zustand/traditional'. The usage with one or two arguments is not deprecated.
 * https://github.com/pmndrs/zustand/discussions/1937
 */
export function useStore<S extends WithReact<StoreApi<unknown>>, U>(
  api: S,
  selector: (state: ExtractState<S>) => U,
  equalityFn: ((a: U, b: U) => boolean) | undefined,
): U

export function useStore<TState, StateSlice>(
  api: WithReact<StoreApi<TState>>,
  selector: (state: TState) => StateSlice = identity as any,
  equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
) {
  if (
    import.meta.env?.MODE !== 'production' &&
    equalityFn &&
    !didWarnAboutEqualityFn
  ) {
    console.warn(
      "[DEPRECATED] Use `createWithEqualityFn` instead of `create` or use `useStoreWithEqualityFn` instead of `useStore`. They can be imported from 'zustand/traditional'. https://github.com/pmndrs/zustand/discussions/1937",
    )
    didWarnAboutEqualityFn = true
  }
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getServerState || api.getInitialState,
    selector,
    equalityFn,
  )
  useDebugValue(slice)
  return slice
}

조금 길긴 하지만 별 내용이 없다. create의 결과인 훅은 최종적으로 이곳에서 만들어 진다. 위에서도 그러하듯이 equalityFn을 직접 넘기는 것은 deprecated 됐다.

api는 생성되어 넘겨지고, selector가 주어지거나 안 주어지는 2가지 형태로 오버로딩 되어 있다.

// selector가 주어진 경우
const value = useStore((state) => state.value);
// selector가 주어지지 않은 경우
const value2 = useStore().value;

그리고 주어진 storeApi의 메서드들을 리액트use-sync-external-store 패키지의 useSyncExternalStoreWithSelector에 제공하여 리액트store의 변화에 반응하도록 한다.

해당 훅의 자세한 정보는

rfcs-0214-use-sync-external-store
https://github.com/reactwg/react-18/discussions/86에 기원과 구현이 나온다.

selector를 넘기는 방식은 현재 리액트에서 하위호환으로 유지해주고 있기에, 라이브러리의 자체 구현으로 변경해야한다. 그러나 4.X 버전에서는 현재 구현 방식을 유지하고 5.X 버전에서 자체 구현(useMemo를 사용한)으로 변경할 것으로 보인다(https://github.com/pmndrs/zustand/blob/v5.0.0-alpha.5/src/react.ts)

// createImple에서 useStore 사용 부
const useBoundStore: any = (selector?: any, equalityFn?: any) =>
    useStore(api, selector, equalityFn)

Object.assign(useBoundStore, api)

return useBoundStore

useBoundStore를 호출해서 리턴되는 slicereact state이다. store를 외부에서 접근해서 여러 메서드를 사용 가능하도록 storeApiuseBoundStore에 할당해준다.

우리는 이 결과물 오브젝트를 호출하여 반응 상태를 얻거나 메서드를 호출하여 state를 조작하는 등의 일이 가능하다.

여기까지가 zustand의 핵심 코드에 대한 분석이다.간단하게 구현 부분만 요약하면 다음과 같다.

후기

권장 사례에 대한 이해

https://tkdodo.eu/blog/working-with-zustand 참조했습니다.

selector를 사용해 store의 상태를 획득하는 것을 권장하는 이유

// 권장
const value = useStore((state) => state.value);

// 권장 안함
const value2 = useStore.getState().value;
  • selector가 없다면 store 내부의 다른 상태에 변화가 발생하더라도 object의 id가 갱신되기에 다른 값으로 본다.
    • 내부 비교에 대한 로직은 퍼포먼스 이유로 생략된 것 같음.

actions를 별도로 권장하고 selector로 한번에 선택하길 권장하는 이유

// 출처 블로그 예시
const useBearStore = create((set) => ({
  bears: 0,
  fish: 0,
  // ⬇️ separate "namespace" for actions
  actions: {
    increasePopulation: (by) =>
      set((state) => ({ bears: state.bears + by })),
    eatFish: () => set((state) => ({ fish: state.fish - 1 })),
    removeAllBears: () => set({ bears: 0 }),
  },
}))

export const useBears = () => useBearStore((state) => state.bears)
export const useFish = () => useBearStore((state) => state.fish)

// 🎉 one selector for all our actions
export const useBearActions = () =>
  useBearStore((state) => state.actions)
  • 만약 actions라는 오브젝트를 가지지 않고 루트 오브젝트에 둔다면 값 변화로 인해 영향을 받나?
    • 그렇지는 않다.
  • 어차피 actions가 변하지 않으니까 한번에 선택하라는 의미인듯!!
const a = {
    get: () => 1,
  };

const b = {};
Object.assign(b, a);
// false, true
console.log(Object.is(a, b), Object.is(a.get, b.get)); 

store 규모를 작게 유지하는 이유

  • 굳이 크게 가질 이유가 없음
    • 규모가 크면 하나의 프로퍼티 갱신 시 모든 프로퍼티 복사해야해서 비효율?

개인적인 생각

v5를 분석해볼걸 싶다..

의도에 대해서 좀 더 분석해보고 싶었는데, 사소한건 넘어간게 아쉽다.

왜 저런 형태로만 미들웨어를 사용하도록 강제했는지? 같은 궁금증이 생기긴 한다.

틀린 해석이나 논쟁의 여지가 있는 부분은 댓글로 남겨주시면 고민해서 반영하겠습니다.

profile
개발자 지망생입니다.

0개의 댓글