[번역] Zustand와 React Context

오정진 Jeongjin Oh·2024년 4월 26일
3
post-thumbnail

최근 zustand의 간단하고 미니멀한 API에 매력을 느끼기 시작했습니다. 이 아티클의 고민과 솔루션이 인상깊고 공감되는 부분이 있어 번역을 진행합니다. 아래는 원문 링크입니다.

https://tkdodo.eu/blog/zustand-and-react-context

Zustand는 전역 클라이언트 상태 관리를 위한 훌륭한 라이브러리입니다. 이 라이브러리는 간단하고, 빠르며 번들 크기도 작습니다. 하지만 한 가지 마음에 들지 않는 점이 있습니다.

스토어는 전역적입니다.

아시겠죠? 하지만 전역 상태 관리의 요점이 바로 여기에 있지 않나요? 어디서나 앱에서 해당 상태를 사용할 수 있도록 하는 것 아닐까요?

가끔은 그렇게 생각하기도 합니다. 하지만 지난 몇 년 동안 zustand를 어떻게 사용해왔는지 살펴보면 전체 애플리케이션이 아니라 하나의 컴포넌트의 하위 트리에서 전역적으로 사용할 수 있는 상태가 필요한 경우가 더 많았다는 걸 깨달았습니다. zustand를 사용하면 기능별로 여러개의 작은 스토어를 만드는 것도 괜찮고 심지어 권장할만합니다. 그렇다면 대시보드 경로에서만 필요한데 대시보드 필터 스토어를 전역적으로 사용할 수 있어야 하는 이유는 무엇일까요? 물론 문제가 되지는 않는다면 그렇게 할 수 있지만 전역 스토어에는 몇 가지 단점이 있다는 것을 알게 되었습니다.

props를 통한 초기화

전역 스토어는 React 컴포넌트 생명주기 외부에서 생성되므로 prop으로 얻은 값으로 스토어를 초기화할 수 없습니다. 전역 스토어를 사용하려면 먼저 기본 상태로 스토어를 생성한 다음 useEffect로 props와 스토어를 동기화해야합니다.

const useBearStore = create((set) => ({
  // ⬇️ 기본값으로 초기화한다
  bears: 0,
  actions: {
    increasePopulation: (by) =>
      set((state) => ({ bears: state.bears + by })),
    removeAllBears: () => set({ bears: 0 }),
  },
}))

const App = ({ initialBears }) => {
  //😕 스토어에 initialBears를 쓴다
  React.useEffect(() => {
    useBearStore.set((prev) => ({ ...prev, bears: initialBears }))
  }, [initialBears])

  return (
    <main>
      <RestOfTheApp />
    </main>
  )
}

useEffect를 쓰고싶지 않은 것외에도 두가지 이유로 이상적이지 않습니다.

  1. 먼저 effect가 시작되기 전에 <ResetOfTheApp />bears: 0으로 렌더링한 다음 올바른 initialBears 로 다시 한 번 렌더링합니다.
  2. 실제로 initialBears 로 스토어를 초기화하는 것이 아니라 동기화합니다. 따라서 initialBears 가 변경되면 스토어에도 업데이트가 반영되는 것을 볼 수 있습니다.

테스팅

zustand에 대한 테스트 문서는 꽤 혼란스럽고 복잡합니다. zustand를 mocking하고 테스트 사이에 스토어를 재설정하는 등에 관한 내용이 전부입니다. 이 모든 것이 스토어가 전역적이라는 사실에서 비롯된 것 같습니다. 컴포넌트 하위 트리로 범위가 지정되면 해당 컴포넌트를 렌더링할 수 있고 스토어는 해당 컴포넌트에서 격리되어 이런 ‘해결방법’이 필요하지 않을 것입니다.

재사용성

모든 스토어가 앱에서 한 번만 사용하거나 특정 경로에서 한 번만 사용할 수 있는 싱글톤은 아닙니다. 때로는 재사용할 수 있는 컴포넌트를 위한 zustand 스토어를 원합니다. 제가 생각할 수 있는 과거의 한 예로는 디자인 시스템의 복잡한 다중 선택 그룹(multi-selection group) 컴포넌트입니다. 이 컴포넌트는 선택항목의 내부 상태를 관리하기 위해 React Context를 사용해 내려받은 로컬 상태를 사용하고 있었습니다. 항목이 50개 이상이 되자 항목을 선택할 때마다 속도가 느려졌습니다. 이것이 제가 아래 트윗을 쓴 이유입니다.

이러한 zustand 스토어가 전역이라면 서로의 상태를 공유하고 덮어쓰지 않고는 컴포넌트를 여러번 인스턴스화할 수 없습니다.

역주
전역스토어는 컴포넌트에서 공유하는 상태이기 때문에 재사용할 수 있는 컴포넌트(예를 들면 <select />)에서 전역 스토어를 구독하면 그 컴포넌트를 사용하는 곳은 모두 같은 상태를 공유합니다. 한 곳에서 상태를 변경하면 스토어를 공유하는 컴포넌트의 상태는 덮어씌워집니다. 그래서 전역스토어를 구독하는 컴포넌트를 재사용을 할 수 없다는 의미입니다. 즉, 컴포넌트를 인스턴스화할 수 없다는 것입니다.


흥미롭게도 이런 문제를 해결해줄 방법이 있습니다.

React Context

Context를 상태 관리 도구로 사용하는 것이 애초에 앞서 언급한 문제를 일으켰기 때문에 React Context가 해결책이라는 것은 재미있고 아이러니한 일입니다. 하지만 제가 제안하는 것은 그런 것이 아닙니다. 이 아이디어는 스토어 값 자체가 아니라 React Context를 통해 스토어 인스턴스를 공유하자는 것입니다.

개념적으로 이것은 React Query의 <QueryClientProvider />로 하는 일이며, redux가 단일 스토어로 하는 일과도 같습니다. 스토어 인스턴스는 자주 변경되지 않는 정적 싱글톤이기 때문에 리렌더링 문제를 일으키지 않고 쉽게 React Context에 넣을 수 있습니다. 그런 다음 zustand에 의해 최적화될 스토어 구독자를 계속 생성할 수 있습니다. 그 모습은 다음과 같습니다.

v5 구문
이 아티클에서 React Context와 zustand를 결합하는 v5 구문을 보여드리겠습니다. 그 전에는 zustand에서 명시적으로 zustand/contextcreateContext함수를 내보냈습니다.

import { createStore, useStore } from 'zustand'

const BearStoreContext = React.createContext(null)

const BearStoreProvider = ({ children, initialBears }) => {
  const [store] = React.useState(() =>
    createStore((set) => ({
      bears: initialBears,
      actions: {
        increasePopulation: (by) =>
          set((state) => ({ bears: state.bears + by })),
        removeAllBears: () => set({ bears: 0 }),
      },
    }))
  )

  return (
    <BearStoreContext.Provider value={store}>
      {children}
    </BearStoreContext.Provider>
  )
}

여기서 가장 큰 차이점은 이전과 같이 바로 사용할 수 있는 hook을 제공해주는 create를 사용하지 않았다는 점입니다. 대신, 우리는 바닐라 zustand 함수인 createStore에 의존하고 있으며 이 함수는 우리를 위해 스토어를 생성합니다. 그리고 컴포넌트 내부에서든 원하는 곳 어디에서나 이 작업을 수행할 수 있습니다. 하지만 저장소 생성이 한 번만 발생하도록 해야합니다. 이 작업은 ref 로 할 수 있지만 저는 이를 위해 useState를 사용하는 걸 선호합니다. 그 이유를 알고 싶으시면 해당 주제에 대한 별도의 블로그 게시물을 참고해주세요.

컴포넌트 내부에서 스토어를 생성하기 때문에 initialBears와 같은 props를 가지고 이를 실제로 초기값으로 createStore에 전달할 수 있습니다. useState의 초기화 함수는 한 번만 실행되므로 prop에 대한 업데이트가 스토어로 전달되지 않을 것입니다. 그런 다음 스토어 인스턴스를 가져와서 React Context로 넘겨줍니다. 여기에는 zustand의 특별한 것이 없습니다.


그 후에는 스토어에서 일부 값을 선택하고자 할 때마다 컨텍스트를 사용해야 합니다. 이를 위해 storeselector를 zustand에서 가져올 수 있는 useStore hook으로 전달해야 합니다. 이는 커스텀 hook으로 추상화하는 것이 가장 좋습니다.

const useBearStore = (selector) => {
  const store = React.useContext(BearStoreContext)
  if (!store) {
    throw new Error('Missing BearStoreProvider')
  }
  return useStore(store, selector)
}

그런 다음 익숙한 것처럼 useBearStore hook을 사용하고 amotic한 selector를 사용해 커스텀 hook을 내보낼 수 있습니다.

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

전역 스토어를 만드는 것보다 작성해야 하는 코드가 조금 더 많지만 세 가지 문제를 모두 해결할 수 있습니다:

  1. 예제에서 볼 수 있듯이, 이제 React 컴포넌트 트리 안에서 스토어를 생성하기 때문에 props로 스토어를 초기화할 수 있습니다.

  2. 테스트는 매우 쉬워졌는데, BearStoreProvider가 포함된 컴포넌트를 렌더링하거나 테스트를 위해 직접 렌더링할 수 있기 때문입니다. 두 경우 모두 생성된 스토어는 테스트에 완전히 격리되므로 테스트 사이에 재설정할 필요가 없습니다.

  3. 이제 컴포넌트는 BearStoreProvider를 렌더링하여 자식에게 캡슐화된 스토어를 제공할 수 있습니다. 이 컴포넌트를 한 페이지에서 원하는 만큼 자주 렌더링할 수 있으며, 각 컴포넌트 인스턴스에는 자체 스토어가 있으므로 재사용성을 확보할 수 있습니다.

    역주
    컴포넌트 안에서 zustand 스토어를 만들고 React Context 로 스토어를 넘겨주기 때문에 Context를 사용하는 컴포넌트마다 각각의 스토어를 가지게 됩니다. 예를 들면 <Select /> 컴포넌트가 zustand 스토어를 Context로 받는다면 <Select />마다 스토어를 가진 것입니다. 결국 각 컴포넌트는 상태를 공유하지 않게 된 것입니다.

따라서 스토어에 접근하기 위해 Context Provider가 필요하지 않다고 zustand 문서에서는 자랑스럽게 말하지만 스토어 생성과 React Context를 결합하는 방법을 아는 것은 캡슐화 및 재사용이 필요한 상황에서 매우 유용할 수 있다고 생각합니다. 저는 이 추상화를 진정한 전역 스토어보다 더 많이 사용했습니다. 😄


오늘은 여기까지입니다. 궁금한 점이 있으면 트위터로 저에게 연락하거나 아래에 댓을을 남겨주세요. ⬇️

profile
단 한사람의 불편함이라도 해결해 줄 수 있는 개발자가 되고 싶습니다.

0개의 댓글