React deep dive -12_Zustand

조용환·2024년 3월 27일
0

React_Deep_Dive

목록 보기
12/12

Zustand

Zustand 리덕스에 영감을 받아 만들어졌다. 하나의 스토어를 중앙 집중형으로 활용해 이 스토어 내부에서 상태를 관리하고 있다.
(2022년 9월 기준 Zustand 최신 버전인 4.1.1을 기준으로 한다.)

Zustand의 바닐라 코드

// Zustand store 코드
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: SetStateInternal<TState> = (partial, replace) => {
    // ...
    const nextState =
       typeof partial === 'function' ? (partial as(state:TState) => Tstate(state) : partial
    if (nextState !== state) {
      const previousState =state
      state =
        replace ?? typeof nextState !== 'object' ? (nextState as TState) : Object.assign({}, state, nextState)
      listeners.forEac((listener) => listener(state, previousState))
    }
  }
 
  const getState: () => TState = () => state
  
  const subscribe: (listener: Listener) => () => void = (listener) => {
    listeners.add(listener)
    //Unsubscribe
    return () => listeners.delete(listener)
  }
  
  const destroy: () => void = () => listeners.clear()
  cosnt api = { setState, getState, subscribe, destroy }
  state = ( createState as PopArgument<typeof createState>)(setState, getState, api,
                                                            )
  return api as any
}
  • 스토어의 구조가 state값을 useState 외부에서 관리하는 것을 볼 수 있다.
  • state라고 하는 변수가 스토어의 상태값을 담아두는 곳이며, setState는 이 state 값을 변경하는 용도로 만들어졌다.
  • 특이한 점은 partial, replace인데 partial은 state 일부분 변경, replace는 전체 변경 시 사용할 수 있다.
  • getState는 클로저 최신 값 가져올 때 쓰고, subscribe는 listener를 등록하는 데, Set 형태로 선언되어 추가, 삭제 중복 관리가 용이하게끔 설계돼 있다.
  • 상태값 변경 시 리렌더링이 필요한 컴포넌트에 전파될 목적으로 만들어졌음을 알 수 있다.
  • destroy는 listeners를 초기화하는 역할을 한다.
  • createStore는 이렇게 만들어진 getState, setState, subscribe, destroy를 반환하고 있다.
  • import 하는게 없는 바닐라 js로 이루어져 있다.
//./src/vanilla.ts
type CounterStore = {
  count: number
  increase: (num: number) => void
}
  
const store = createStore<CounterStore>((set) => ({
  count: 0,
  increase: (num: number) => set((state) => ({ count: state.count + num })), }))

store.subscribe((state, prev) => {
  if (state.count !== prev.count) {
    console.log('count has been changed', state.count)
  }
})

store.setState((state) => ({ count : state.count +1 }))

store.getState().increase()

Zustand의 리액트 코드

Zustand를 리액트에서 사용하기 위해서는 어디선가 store를 읽고 리렌더링을 해야 한다. Zustand 스토어를 리액트에서 사용할 수 있도록 도와주는 함수들은 ./src/react/ts에서 관리되고 있다. 타입을 제외하고 여기에서 export하는 함수는 바로 useStore와 create다. 먼저 useStore를 살펴보자.

// Zustand의 useStore 구현
export function useStore<TState, StateSlice>(
  api: WithReact<StoreApi<TState>>,
  selector : (state: TState) => StateSlice = api.getState as any,
  equalityFn?: (a: StateSlice, b: StateSlice) => boolean,
) {
  const slice = useSyncExternalStoreWithSelector(
    api.subscribe,
    api.getState,
    api.getSrverState || api.getState,
    selector,
    equalityFn,
  )
  useDebugValue(slice)
  return slice
}
  • useSyncExternalStoreWithSelector를 사용해서 앞선 useStore의 subscribe, getState를 넘겨주고, 스토어에서 선택을 원하는 state를 고르는 함수인 selector를 넘겨주고 끝난다.
  • useSyncExternalStoreWithSelector는 useSyncExternalStore와 완전히 동일하지만 원하는 값을 가져올 수 있는 selector와 동등 비교를 할 수 있는 equalityFn 함수를 받는다는 차이가 있다.
  • 즉 객체가 예상되는 외부 상태값에서 일부 값을 꺼내올 수 있도록 useSyncExternalStoreWithSeletor를 사용했다.

또 한 가지, ./src/react.ts에서 export하는 변수는 바로 create인데, 이는 리액트에서 사용할 수 있는 스토어를 만들어주는 변수다.

const createImpl = <T>(createState: StateCreator<T, [], []>) => {
  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
}

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

export default create

리액트의 create는 바닐라 createStore를 기반으로 만들어졌기 때문에 거의 유사하다. 또한 간결한 구조 덕분에 리액트 환경에서도 스토어를 생성하고 사용하기가 매우 쉽다.

interface Store {
  count: number
  text: string
  increase: (count: number) => void
  setText: (text: string) => void
}
  
const store = createStore<Store>((set) => ({
  count: 0,
  text: '',
  increase: (num) => set((state) => ({ count: state.count + num })),
  setText: (text) => set({ text}),
}))

const counterSelector = ({ count, increase }: Store) => ({
  count, increase,
})

function Counter() {
  const{ count, increase} = useStore(store, counterSelector)
  
  function handleClick() {
    increase(1)
  }
  
  return (
    <>
    <h3>{count}</h3>
    <button onClick={handleClick}>+</button>
  	</>
  )
}

const inputSelector = ({ text, setText}: Store) => ({
  text,
  setText,
})

function Input() {
  const {text, setText} = useStore(store, inputSelector)
  
  useEffect(() => {
    console.log('Input Changed')
  })
  
  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setText(e.target.value)
  }
  
  return (
    <div>
      <input value={text} onChange={handleChange} />
    </div>
  )
}

스토어 생성 자체는 앞선 예제와 동일하며, useStore를 사용하면 이 스토어를 리액트에서 사용할 수 있게 된다.
create를 사용해 스토어를 만들면 useStore를 굳이 사용하지 않더라도 바로 사용 가능하다.

간단한 사용법

import {create} from 'zustand'

const useCounterStore = create((set) => ({
  count: 1, 
  inc: () => set((state) => ({count:state,count +1})),
  dec: () => set((state) => ({count: state.count -1})),
}))

function Counter() {
  const { count, inc, dec } = useCounterStore()
  return (
    <div class="counter">
      <span>{count}</span>
      <button onClick={inc}>up</button>
	  <button onClick={dec}>down</button>
    </div>
  )
}

Zustand의 create를 통해 스토어를 만들고 반환 값으로 이 스토어를 컴포넌트 내부에서 사용할 수 있는 훅을 받았다. 이 훅으로 getter, setter 모두 접근해 사용 가능하게 된다.

특징

Zustand는 특별히 많은 코드를 작성하지 않아도 빠르게 스토어를 만들고 사용할 수 있다는 큰 장점이 있다. 라이브러리 크기 역시 Bundlephobia 기준 79.1kB인 Recoil, 13.1kB인 Jotai와 다르게 Zustand는 고작 2.9kB이다.
또한 미들웨어 역시 지원하는데, create의 두 번째 인수로 원하는 미들웨어를 추가하면 된다. 스토어 데이터 영구 보존할 수 있는 persist, 복잡한 객체 관리 도와주는 immer, 리덕스 미들웨어 등의 사용이 가능하다.

profile
practice react, javascript

0개의 댓글