bgyoonsContact

zustand 오픈소스 뜯어보기

FrontEnd | | 25분 읽기

zustand 오픈 소스 분석

테스트 코드 먼저

오픈소스를 분석할 때 테스트 코드부터 보기 >> 결과를 예측할 수 있고 어떠한 예외가 있는지 알 수 있음


```javascript
// 스토어 사용하는 것
it('uses the store', async () => {
  const store = createStore<CounterState>((set) => ({
    count: 0,
    inc: () => set((state) => ({ count: state.count + 1 })),
  }))
  store.getState().inc()

  expect(store.getState().count).toBe(1)
})

// 값 가져오기
it('can get the store', async () => {
  type State = {
    value: number
    getState1: () => State
    getState2: () => State
  }

  const store = createStore<State>((_, get) => ({
    value: 1,
    getState1: () => get(),
    getState2: (): State => store.getState(),
  }))

  expect(store.getState().getState1().value).toBe(1)
  expect(store.getState().getState2().value).toBe(1)
})

// 값 셋팅하기
it('can set the store', async () => {
  type State = {
    value: number
    setState1: StoreApi<State>['setState']
    setState2: StoreApi<State>['setState']
  }

  const store = createStore<State>((set) => ({
    value: 1,
    setState1: (v) => set(v),
    setState2: (v): void => store.setState(v),
  }))

  store.getState().setState1({ value: 2 })
  expect(store.getState().value).toBe(2)
  store.getState().setState2({ value: 3 })
  expect(store.getState().value).toBe(3)
})
```

create vs createStore 함수의 차이

create 함수란?

스토어를 생성하는데 사용되는 함수(진입점)

내부에서 createStore을 호출하여 api를 받아옴 (클로저를 이용해서 상태 관리)

useStore를 통해 React가 상태 변화를 감지하여 업데이트 할 수 있도록 함 (useSyncExternalStore)

react 훅을 반환함 → 리액트 훅이 스토어

const createImpl = <T>(createState: StateCreator<T, [], []>) => {
  // createStore가 상태 생성 및 변경을 담당
  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

createStore 함수란?

순수 바닐라 스토어 함수 → create와 달리 리액트와 스토어가 분리되어 있음

내부에서 상태와 리스너를 생성하여 setState, getState, subscribe, .. 스토어 api를 반환하는 함수 (클로저)

이 객체를 Context 등으로 전달해 여러 곳에서 공유할 수 있음

SSR과 createStore

createStore(...) + Context 방식

여러 스토어 인스턴스를 유연하게 생성할 수 있고 Context와 함께 사용하면 인스턴스 간 분리가 가능

import { createContext, useContext } from 'react';
import { createStore, useStore } from 'zustand';
 
const zustandContext = createContext(null);
 
export const StoreProvider = zustandContext.Provider;
 
export const useStoreSSR = (selector) => {
  const store = useContext(zustandContext);
  if (!store) throw new Error('Store is missing the provider');
 
  return useStore(store, selector);
};
 
export const initializeStore = (preloadedState) => {
  return createStore((set, get) => ({
    ...preloadedState,
    test: 'initial',
    setTest: (test) => set((state) => ({ ...state, test })),
    getTest: () => get().test,
  }));
};

SSR 환경에서 주의할 점

bookmark

  • Per-request store: A Next.js server can handle multiple requests simultaneously. This means that the store should be created per request and should not be shared across requests.
  • SSR friendly: Next.js applications are rendered twice, first on the server and again on the client. Having different outputs on both the client and the server will result in "hydration errors." The store will have to be initialized on the server and then re-initialized on the client with the same data in order to avoid that. Please read more about that in our SSR and Hydration guide.
  • SPA routing friendly: Next.js supports a hybrid model for client side routing, which means that in order to reset a store, we need to initialize it at the component level using a Context.
  • Server caching friendly: Recent versions of Next.js (specifically applications using the App Router architecture) support aggressive server caching. Due to our store being a module state, it is completely compatible with this caching.

  1. Hydration Error

    Next.js 애플리케이션은 서버에서 한 번, 클라이언트에서 한 번 두 번 렌더링됨

    클라이언트와 서버에서 서로 다른 출력이 발생하면 Hydration Error 발생

     ex) 시간(`new Date()`), 랜덤(`Math.random()`)한 값 ..
    

    → 이를 방지하려면 서버에서 스토어를 초기화한 다음 클라이언트에서 동일한 데이터로 다시 초기화

  2. 같은 스토어 인스턴스 공유 Node.js 서버 환경에서 JavaScript 모듈은 기본적으로 모듈이 처음 불러와질 때 한 번 초기화되고, 이후의 모든 import는 동일한 인스턴스를 참조

    Next.js 서버는 여러 요청을 동시에 처리할 수 있음

    → 서버에서 모든 사용자 요청이 같은 store 인스턴스를 공유하게 되어 유저마다 상태가 달라야 하는 SSR 환경에서는 문제가 될 수 있음

    1. 사용자 A가 웹사이트에 요청을 보냅니다.
    2. 서버는 A의 요청을 처리하고, 
       그 과정에서 useUserStore의 login 액션을 통해 A의 사용자 정보를 스토어에 저장합니다.
    3. 서버는 A의 사용자 정보가 담긴 HTML을 생성하고 응답합니다.
    4. 곧바로, 사용자 B가 동일한 웹사이트에 요청을 보냅니다.
    5. 서버는 B의 요청을 처리하는데, useUserStore는 여전히 A의 정보를 가지고 있습니다.
    6. 서버는 의도치 않게 A의 정보가 포함된 HTML을 B에게 응답할 수 있습니다.

    ⇒ 스토어는 요청별로 생성되어야 하며 요청 간에 공유되어서는 안됨

    모듈 레벨이란?

    모듈 레벨이란 JavaScript 파일이 처음 로드될 때 바로 실행되는 코드의 범위
    함수나 컴포넌트 내부가 아닌, 파일의 최상위 레벨에서 실행되는 코드
    단위가 파일? → 1 스크립트 - 1 모듈

    create는 모듈 레벨에서 단일 스토어 인스턴스를 생성

    import { create } from 'zustand';
     
    // 모듈 레벨에서 실행
    const useStore = create((set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }));
     
    // 함수 내부는 모듈 레벨 X
    function Component() {
      // 컴포넌트가 렌더링될 때만 실행
    }

    → 서버에서 실행될 때, 사용자 요청마다 같은 스토어 인스턴스를 공유할 수 있음

    createStore은 컴포넌트나 함수 내부에서 스토어를 생성할 수 있음 (함수형 팩토리 패턴?)

    import { createStore } from 'zustand/vanilla';
     
    // 스토어 팩토리 함수 정의
    const createCounterStore = () => 
      createStore((set) => ({
        count: 0,
        increment: () => set((state) => ({ count: state.count + 1 })),
      }));
     
    // 사용 시 (다른 파일에서)
    import { useStore } from 'zustand';
    import { createCounterStore } from './store';
     
    // 컴포넌트 내부에서 새 인스턴스 생성
    function Component() {
      const store = createCounterStore();
      const count = useStore(store, (state) => state.count);
    }

    → 각 요청이나 컴포넌트 인스턴스마다 독립적인 스토어를 생성

    컨텍스트와 함께 사용하면, 각 Provider마다 고유한 스토어 인스턴스를 전달

closure란 무엇일까

클로저란?

  • 함수와 그 함수가 선언됐을 때의 렉시컬 환경(Lexical environment)과의 조합

    • 렉시컬 환경이란 식별자와 식별자가 바인딩된 값, 그리고 상위 스코프에 대한 참조를 기록

    렉시컬 환경 안에는 식별자를 관리하는 환경 레코드, 상위 스코프의 렉시컬 환경을 바인딩한 외부 렉시컬 환경이 등록되어 있음 ⇒ 식별자와 스코프 체인을 관리하는 저장소 역할

    소스 코드 실행이 종료되어 실행 컨텍스트 스택이 pop 되지만 참조하고 있는 식별자가 남아있는 렉시컬 환경은 사라지지 않음 + 가비지 컬렉터가 메모리를 회수하지 않음 ⇒ 클로저

  • 함수가 **선언될 당시의 렉시컬 스코프(lexical scope)**를 기억
  • 자신을 포함하고 있는 외부함수보다 내부함수가 더 오래 유지되는 경우, 외부 함수 밖에서 내부함수가 호출되더라도 외부함수의 지역 변수에 접근할 수 있는 함수

zustand에서 클로저를 사용하는 대표 예시

const createStoreImpl = (createState) => {
  let state; // 접근 가능
  const listeners = new Set();
 
  // state와 listeners에 접근 가능한 클로저를 형성
  const setState = (partial, replace) => {
    const nextState =
      typeof partial === 'function' ? partial(state) : partial;
 
    if (!Object.is(nextState, state)) {
      const previousState = state;
      state =
        (replace ?? (typeof nextState !== 'object' || nextState === null))
          ? nextState
          : Object.assign({}, state, nextState);
      listeners.forEach((listener) => listener(state, previousState));
    }
  };
 
  const getState = () => state;
 
  const getInitialState = () => initialState;
 
  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };
 
  const api = { setState, getState, getInitialState, subscribe };
  const initialState = (state = createState(setState, getState, api)); // 호이스팅(getInitialState)
  return api;
};
 
const createStore = ((createState) =>
  createState ? createStoreImpl(createState) : createStoreImpl) as CreateStore
 
const createImpl = <T>(createState: StateCreator<T, [], []>) => {
  // { setState, getState, getInitialState, subscribe }를 리턴
  // state, listeners에 간접적으로 접근 가능
  // 오직 반환된 API 메서드를 통해서만 상태에 접근하고 조작
  // -> 상태 변경이 항상 제어된 방식으로만 이루어지도록 보장
  // createStore 함수가 실행을 마치고 반환된 후에도, 
  // 반환된 api 객체의 메서드들은 여전히 그 렉시컬 환경에 있는 변수들을 참조
  const api = createStore(createState)
  const useBoundStore: any = (selector?: any) => useStore(api, selector)
  Object.assign(useBoundStore, api)
  return useBoundStore
}

발제

공부필요

useSyncExternalStore훅의 역할은 무엇일지

React 18에서 도입된 훅으로, 외부(External) 상태 저장소의 변화를 React 컴포넌트와 동기화하기 위해 사용됩니다.

여기서 “외부 스토어”란, Redux, MobX, 브라우저 API, 커스텀 클래스 등 React의 내부 상태(useState, useContext 등)로 관리되지 않는 데이터를 의미합니다.

주요 목적

외부 데이터 소스(스토어)의 상태 변화를 React 컴포넌트에서 안전하고 일관되게 감지하고, 자동으로 리렌더링을 트리거합니다.

기존에는 useEffect와 useState를 조합해 외부 소스를 구독했으나, useSyncExternalStore는 이 과정을 더 간결하고 최적화된 방식으로 처리합니다.

import { useSyncExternalStore } from 'react';
 
function useCustomStore(store) {
  const state = useSyncExternalStore(
    store.subscribe,          // 변화 구독 함수
    store.getSnapshot,        // 현재 상태 반환 함수
    store.getServerSnapshot   // (SSR에서 사용, 선택적)
  );
  return state;
}

subscribe: 외부 스토어의 변화를 구독하는 함수(리스너 등록/해제).

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

getServerSnapshot: 서버 사이드 렌더링(SSR)에서 사용할 스냅샷 반환 함수(선택).

콘솔 값이 다른 이유 (틀릴수도)

const usePersonStore = create<State & Action>((set, get) => ({
  firstName: 'initial',
  updateFirstName: (firstName) => set(() => ({ firstName: firstName })),
  getFirstName: () => get().firstName,
}))
 
const store = usePersonStore()
// const firstName = usePersonStore((state) => state.firstName);
 
useEffect(() => store.updateFirstName("yoonseo"), [])
 
console.log(store.firstName)
console.log(store.getFirstName())

store = usePersonStore()는 훅 호출 시점의 상태를 스냅샷처럼 가지고 있음

React는 usePersonStore() 훅을 호출할 때, selector로 선택된 값만 자동으로 구독하고 리렌더링 트리거에 연결 (useSyncExternalStore 훅을 통해 특정 값의 변화를 감지)

const store = usePersonStore() 처럼 전체 스토어를 가져오면, **리렌더링과 연결되지 않을 수 있음 (**어떤 특정 상태값의 변화에 리렌더링을 연결해야 할지 모르기 때문) → 그래서 상태 변경 반영이 안됨

즉, store.firstName은 초기 렌더 시점의 값인 "initial"을 그대로 보여주게 됨

→ 상태가 업데이트되어도 컴포넌트가 리렌더링되지 않는 한 이 값은 갱신되지 않음

store.getFirstName()은 Zustand 스토어의 내부 get 함수를 사용하므로 항상 최신 상태를 반환

컴포넌트의 렌더링 주기와 무관하게 실시간으로 스토어에서 값을 읽어옴

const firstName = usePersonStore((state) => state.firstName);

selector를 써야 상태가 변경될 때마다 컴포넌트도 자동으로 리렌더 됐을 거다 ..

zustand는 어떻게 Providerless 할 수 있을까?

useSyncExternalStore훅 → 외부스토어 리액트 내 동기화

  1. 모듈 레벨 스토어: Zustand는 스토어를 React 컴포넌트 트리 외부의 모듈 레벨에서 생성하고 유지합니다. 이는 스토어가 컴포넌트 계층 구조와 독립적으로 존재할 수 있게 합니다.

    // store.js - 앱 어디서나 접근 가능한 모듈 레벨 스토어
    const useStore = create(...)
  2. 클로저 기반 상태 관리: Zustand는 클로저를 활용하여 스토어 상태를 모듈 내부에 캡슐화하고, 상태 변경 감지와 구독 메커니즘을 관리합니다.

  3. React 18 이후의 useSyncExternalStore: React 18에서 도입된 useSyncExternalStore 훅은 Zustand의 Provider 없는 접근 방식을 더 안정적으로 만들었습니다. 이 훅은 외부 스토어의 변경을 React 렌더링 시스템과 동기화하기 위해 설계되었습니다.

참고자료

https://www.nextree.io/zustand/

https://ui.toast.com/weekly-pick/ko_20210812

https://zustand.docs.pmnd.rs/guides/nextjs