yunicornlab

Zustand의 create 함수 알아보기 (2) - create와 useStore 본문

Development/State

Zustand의 create 함수 알아보기 (2) - create와 useStore

yunicornlab 2024. 12. 12. 01:44
반응형

create와 useStore 정의 코드 살펴보기

이전 글에서 src > react.ts 파일에 있는 코드를 다시 살펴보자

https://github.com/pmndrs/zustand/blob/a958de910fb49392d5407eb0a9a776ec959ce8c5/src/react.ts

 

zustand/src/react.ts at a958de910fb49392d5407eb0a9a776ec959ce8c5 · pmndrs/zustand

🐻 Bear necessities for state management in React. Contribute to pmndrs/zustand development by creating an account on GitHub.

github.com

import React from 'react'
import { createStore } from './vanilla.ts'
import type {
  Mutate,
  StateCreator,
  StoreApi,
  StoreMutatorIdentifier,
} from './vanilla.ts'

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 = React.useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
    () => selector(api.getInitialState()),
  )
  React.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

 

 

로직에 집중하기 위해 타입스크립트로 작성된 코드를 일단 자바스크립트 코드로 바꾼 후 살펴보려 한다.

import React from 'react';
import { createStore } from './vanilla';

const identity = (arg) => arg;

export function useStore(api, selector = identity) {
  const slice = React.useSyncExternalStore(
    api.subscribe,
    () => selector(api.getState()),
    () => selector(api.getInitialState())
  );
  React.useDebugValue(slice);
  return slice;
}

const createImpl = (createState) => {
  const api = createStore(createState);

  const useBoundStore = (selector) => useStore(api, selector);

  // Attach the API methods to the hook
  Object.assign(useBoundStore, api);

  return useBoundStore;
};

export const create = (createState) => {
  return createState ? createImpl(createState) : createImpl;
};

create 함수를 사용하게 되면

create 함수 호출 -> createImpl 함수 호출 -> useStore 함수 호출 순서로 동작한다.


createImpl 함수

import { createStore } from './vanilla';

const createImpl = (createState) => {
  const api = createStore(createState);        // 스토어 생성

  const useBoundStore = (selector) => useStore(api, selector); // React 훅과 스토어 연결

  Object.assign(useBoundStore, api);           // 스토어의 메서드들을 React 훅에 병합

  return useBoundStore;                        // React에서 사용할 상태 관리 훅 반환
};

이전 글에서 설명했던 /src/vanilla.ts 파일에 정의된 createStore 함수를 import한다.

createStore 함수는 const api = { setState, getState, getInitialState, subscribe }로 정의된 상태 관리 API 객체 변수를 반환한다.

(구체적으로는 createStoreImpl 함수를 호출하는 과정을 거치고 상태 관리 API 객체를 반환)

즉, 상태를 변경하는 함수인 setState와 상태값을 가져올 수 있는 getState 등이 담긴 객체를 다시 api 변수에 저장하고,

이 api와 selector를 useStore에게 인자로 넘겨준다. 

useStore의 반환값은 외부 스토어에서 현재 상태를 가져온 값으로, selector에 의해 모든 상태를 가져올 지, 선택한 일부만 가져올 지 결정된다. 이건 useBoundStore에 담기게 된다. 이로써 React Hook인 useStore와 createStore를 통해 생성된 스토어가 연결되었다.

그리고 Object.assign으로 useBoundStore에 api 객체를 복사한 후, createImpl 함수는 이 useBoundStore를 반환하게 된다.

 

Object.assign(useBoundStore, api);

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/assign

 

Object.assign() - JavaScript | MDN

Object.assign() 메서드는 출처 객체들의 모든 열거 가능한 자체 속성을 복사해 대상 객체에 붙여넣습니다. 그 후 대상 객체를 반환합니다.

developer.mozilla.org

[ MDN 문서 ]
Object.assign() 메서드는 출처 객체들의 모든 열거 가능한 자체 속성을 복사해 대상 객체에 붙여넣습니다. 
그 후 대상 객체를 반환합니다.

 

useBoundStore는 React 훅처럼 사용되며 useStore(api, selector)를 호출한다.

동시에 Object.assign으로 api와 함께 묶었기 때문에 Zustand api의 메서드(getState, setState, subscribe 등)를 직접 호출할 수 있는 인터페이스를 제공할 수 있게 된다. 즉, useBoundStore는 React 훅과 Zustand 스토어 API를 통합한 객체가 된다.

/* useBoundStore의 최종 상태 예상 */
useBoundStore = {
  // 원래 함수 역할 유지
  (selector?: any) => useStore(api, selector),

  // 추가된 메서드 (api의 속성)
  getState: api.getState,
  setState: api.setState,
  subscribe: api.subscribe,
  getInitialState: api.getInitialState,
};

 

useStore 함수

createImpl 함수를 통해 useStore 함수를 호출하면서 api와 selector를 인자로 넘겨주게 된다.

import React from 'react';

export function useStore(api, selector = (state) => state) {
  const slice = React.useSyncExternalStore(
    api.subscribe,                     // 상태 변경을 구독
    () => selector(api.getState()),    // 현재 상태를 선택
    () => selector(api.getInitialState()) // 서버 사이드 렌더링에서 초기 상태 선택
  );
  React.useDebugValue(slice);          // React DevTools 디버깅용
  return slice;                        // 선택된 상태 반환
}

 

selector

selector가 인자로 주어지지 않으면 기본값이 (state) => state로, 상태 전체를 반환하게 된다.

1. selector가 인자로 주어지지 않는 경우

const store = useStore();

 

이때는 selector = (state) => state이므로 전체 상태를 구독하게 된다.

이렇게 사용하는 것이 권장되지 않는데, 그 이유는 상태 객체에서 필요하지 않은 속성이 변경되어도 컴포넌트가 리렌더링되기 때문에 불필요한 리렌더링이 발생할 수 있기 때문이다.

 

2. selector가 인자로 주어지는 경우

const count = useStore((state) => state.count);

이때는 selector = (state) => state.count가 되므로 상태 객체 중 count만 구독하게 된다.

즉, count 값이 변경될 때만 컴포넌트가 리렌더링되기 때문에 불필요한 리렌더링을 방지할 수 있기 때문이다.

 

 

여기서 잠깐!

export function useStore(api, selector) { } 이기 때문에 첫 번째 인자는 selector가 아니라 api인데?

왜 동작은 첫 번째 인자를 selector로 받는 걸로 되는 걸까?

다시 말해, zustand는 어떻게 selector를 첫 번째 매개변수로 인식하는 걸까?

 

다시 createImpl 함수 코드에서 useBoundStore가 정의된 부분을 보면 다음과 같다.

import { createStore } from './vanilla';

const createImpl = (createState) => {
  const api = createStore(createState);        // 스토어 생성
  const useBoundStore = (selector) => useStore(api, selector); // React 훅과 스토어 연결

  Object.assign(useBoundStore, api);           // 스토어의 메서드들을 React 훅에 병합

  return useBoundStore;                        // React에서 사용할 상태 관리 훅 반환
};

useBoundStore를 정의할 때 useStore 함수를 호출하면서 api와 selector를 인자로 넘겨주는데, 

바로 윗줄에서 api를 고정된 값으로 전달하고 캡슐화하기 때문에, useStore의 첫 번째 매개변수는 사실상 selector만 처리된다.

 

useStore 함수는 React의 Custom Hook이다.

useStore 함수는 결국 React의 Custom Hook이라고 할 수 있다.

(Custom Hook: React의 Hook 규칙을 따르면서, 특정 기능(외부 상태 관리와의 연동)을 구현하기 위해 정의된 사용자 정의 Hook)

그 이유로 React Hook 규칙에 따라 이름이 use로 시작하고, useSyncExternalStore, useDebugValue와 같은 React Hook을 사용하기 때문이다. 

React.useSyncExternalStore(구독 함수, 스냅샷 반환 함수, (옵션-초기 스냅샷 반환 함수))

  • 매개변수 : subscribe, getSnapshot, getServerSnapshot
  • 반환값 : 렌더링 로직에 사용할 수 있는 store의 현재 스냅샷

https://ko.react.dev/reference/react/useSyncExternalStore

 

useSyncExternalStore – React

The library for web and native user interfaces

ko.react.dev

 

이 React Hook의 사용 목적으로는 공식 문서에 나온 문장에서 알 수 있다.

대부분의 React 컴포넌트는 props, state, 그리고 context에서만 데이터를 읽습니다.
하지만 때로는 컴포넌트가 시간이 지남에 따라 변경되는 React 외부의 일부 저장소에서 일부 데이터를 읽어야 하는 경우가 있습니다.
React는 이 함수를 사용해 컴포넌트를 store에 구독한 상태로 유지하고 변경 사항이 있을 때 리렌더링합니다.

 

즉, 외부 상태 관리 라이브러리와 React 컴포넌트를 연결해서, 외부 상태가 변경되었을 때 React 컴포넌트를 렌더링하기 위해 사용한다.

React는 useSyncExternalStore 내부에서 상태를 비교하고, 값이 변경된 경우에만 컴포넌트를 리렌더링해서 불필요한 렌더링을 방지할 수 있게 된다.

 

useStore에서 사용한 React.useSyncExternalStore

1. 첫 번째 인자, 즉 subscribe에는 api.subscribe가 작성되어있다.

[ 공식문서 설명 ]
subscribe는 하나의 callback 인수를 받아 store에 구독하는 함수입니다.
store가 변경될 때, 제공된 callback이 호출되어 React가 getSnapshot을 다시 호출하고 (필요한 경우) 컴포넌트를 다시 렌더링하도록 해야 합니다. subscribe 함수는 구독을 정리하는 함수를 반환해야 합니다.

 

간단히 말해, subscribe는 상태 변경을 구독하기 위한 함수로, 외부 상태가 변경되면 콜백을 호출해 React에게게 상태 변경을 알려준다.

subscribe 함수는 이 글에서 보고 있는 react.ts 파일이 아니라 이전 글에서 본 vanilla.ts 파일에 아래처럼 정의되어 있다. 

(로직만 보기 위해 일단 자바스크립트로 바꾸었다.)

const listeners = new Set()

const subscribe = (listener) => {
  listeners.add(listener);
  // Unsubscribe
  return () => listeners.delete(listener);
};

 

subscribe 함수는 전달받은 인자들을 자바스크립트에서 중복을 허용하지 않는 Set 객체에 추가하게 된다.

그리고 Set 객체에서 삭제하는 구독 해제 함수를 반환한다.

 

api도 마찬가지로 vanilla.ts에서 아래처럼 객체로 정의되어있고, subscribe가 포함된다.

const api = { setState, getState, getInitialState, subscribe }

 

그래서 api.subscribe로 작성한 것이다.

 

2. 두 번째 인자, 즉 getSnapshot에는 () => selector(api.getState())가 작성되어있다.

[ 공식문서 설명 ]
getSnapshot은 컴포넌트에 필요한 store 데이터의 스냅샷을 반환하는 함수입니다.
store가 변경되지 않은 상태에서 getSnapshot을 반복적으로 호출하면 동일한 값을 반환해야 합니다.
저장소가 변경되어 반환된 값이 다르면 (Object.is와 비교하여) React는 컴포넌트를 리렌더링합니다.

 

간단히 말해, getSnapshot은 상태가 변경되었을 때 변경 여부를 비교할 기존의 상태가 저장된 것을 말한다.

getState는 현재 상태를 반환하는 메서드로, React 컴포넌트가 상태를 구독하거나 특정 상태 값을 가져올 때 호출된다.

zustand에서는 selector와 api 객체의 getState 함수를 사용했기 때문에 현재 상태에서 선택된 값을 반환하도록 동작한다.

 

3. 세 번째 인자, 즉 getServerSnapshot에는 () => selector(api.getInitialState())가 작성되어있다. 

[ 공식문서 설명 ]
optional getServerSnapshot: store에 있는 데이터의 초기 스냅샷을 반환하는 함수입니다.
서버 렌더링 도중과 클라이언트에서 서버 렌더링 된 콘텐츠의 하이드레이션 중에만 사용됩니다.
서버 스냅샷은 클라이언트와 서버 간에 동일해야 하며 일반적으로 직렬화되어 서버에서 클라이언트로 전달됩니다.
이 함수가 제공되지 않으면 서버에서 컴포넌트를 렌더링할 때 오류가 발생합니다.

 

간단히 말해, getServerSnapshot은 클라이언트와 서버 간 상태를 동기화하는 데 사용되는 함수다.

getInitialState는 초기 상태를 반환하는 메서드다.

zustand에서는 selector와 getInitialState를 사용했기 때문에 SSR에서 초기 상태를 반환하도록 동작한다.

 

create 함수

export const create = (createState) => {
  return createState ? createImpl(createState) : createImpl;
};

create 함수는 createState가 주어진다면 createState를 인자로 넘겨주면서 createImpl 함수를 호출해 새로운 스토어를 생성한다.

하지만 createState가 없다면 createImpl 함수 자체가 반환된다. (createImpl()로 실행하는 것이 아니라 함수 자체를 반환함)

 

실제로 아래처럼 create에 아무것도 넣지 않고 사용한 후 useStore를 console로 출력해보면

주석에 적어놓은 것처럼 함수 자체가 반환된다.

import { create } from 'zustand';

const useStore = create();
console.log(useStore()) // (selector) => useStore(api, selector)

 

여기서, createState란?

createState는 create 함수에 전달된 첫 번째 인자를 말한다.

예를 들어, 아래 코드가 있다면

const useCounterStore = create((set) => ({
  count: 0, // 초기 상태
  increment: () => set((state) => ({ count: state.count + 1 })), // 상태 증가
  decrement: () => set((state) => ({ count: state.count - 1 })), // 상태 감소
}));

 

createState는 (set) => ({ ... }) 부분이 해당된다.

// createState
(set) => ({
  count: 0, // 초기 상태
  increment: () => set((state) => ({ count: state.count + 1 })), // 상태 증가
  decrement: () => set((state) => ({ count: state.count - 1 })), // 상태 감소
})

 

 

이로써 우리는 zustand의 create 함수를 통해 store를 간단하게 생성하고 상태를 관리할 수 있게 되었다.!!

반응형