wkd2ev

zustands는 어떻게 root provider 없이 상태관리를 할 수 있을까(1)

예제 링크

https://github.com/dkpark10/sangtae

기존에 필자가 사용했던 상태관리 라이브러리 redux, recoil은 root provider를 요구했다. zustand가 대세로 떠오르는 지금 문득 root provider 없이 어떻게 사용이 가능한건지 궁금했다.

react-redux, recoil#

아래는 리덕스 공식문서 튜토리얼에서 가져온 코드이다.

1import React from 'react'
2import ReactDOM from 'react-dom'
3import { Provider } from 'react-redux'
4
5import App from './App'
6import store from './store'
7
8ReactDOM.render(
9  // Render a `<Provider>` around the entire `<App>`,
10  // and pass the Redux store to as a prop
11  <React.StrictMode>
12    <Provider store={store}> <-------------------------- provider
13      <App />
14    </Provider>
15  </React.StrictMode>,
16  document.getElementById('root')
17)

redux는 js용 전역상태 관리라이브러리 이고 react-redux는 react 인터페이스에 맞춰 react에서 사용할 수 있는 일종의 어댑터 역할을 한다. Provider 컴포넌트는 실제로 redux에서 관리하고 있는 전역 상태를 context로 주입시켜 데이터를 전달 시킨다.

https://github.com/reduxjs/react-redux/blob/97d446171cf329a64a59c47156253aac40603874/src/components/Provider.tsx#L23

1return <Context.Provider value={contextValue}>{children}</Context.Provider>

아래는 recoil 예제이다.

1import React from 'react';
2import {
3  RecoilRoot,
4  atom,
5  selector,
6  useRecoilState,
7  useRecoilValue,
8} from 'recoil';
9
10function App() {
11  return (
12    <RecoilRoot>      <-------------------------- provider
13      <CharacterCounter />
14    </RecoilRoot>
15  );
16}

zustand 코드 파헤쳐 보기#

핵심 로직은 생각보다 간단했다. 코드라인도 길지 않았다. 먼저 사용법은 다음과 같다.

1import { create } from 'zustand'
2
3const useStore = create((set) => ({
4  count: 1,
5  inc: () => set((state) => ({ count: state.count + 1 })),
6}))

아래는 zustand의 타입과 불필요한 코드를 제거한 가져온 핵심 코드들이다.

1import useSyncExternalStoreExports from 'use-sync-external-store/shim/with-selector'
2const { useSyncExternalStoreWithSelector } = useSyncExternalStoreExports
3
4const createStore = (createState) => {
5  let state
6  const listeners = new Set()
7
8  const setState = (partial, replace) => {
9    // TODO: Remove type assertion once https://github.com/microsoft/TypeScript/issues/37663 is resolved
10    // https://github.com/microsoft/TypeScript/issues/37663#issuecomment-759728342
11    const nextState =
12      typeof partial === 'function'
13        ? partial(state)
14        : partial
15    if (!Object.is(nextState, state)) {
16      const previousState = state
17      state =
18        replace ?? (typeof nextState !== 'object' || nextState === null)
19          ? nextState
20          : Object.assign({}, state, nextState)
21      listeners.forEach((listener) => listener(state, previousState))
22    }
23  }
24
25  const getState = () => state
26
27  const getInitialState = () => initialState
28
29  const subscribe = (listener) => {
30    listeners.add(listener)
31    // Unsubscribe
32    return () => listeners.delete(listener)
33  }
34
35  const destroy = () => {
36    listeners.clear()
37  }
38
39  const api = { setState, getState, getInitialState, subscribe, destroy }
40  const initialState = (state = createState(setState, getState, api))
41  return api;
42}
43
44export function useStore(
45  api,
46  selector,
47  equalityFn?,
48) {
49  const slice = useSyncExternalStoreWithSelector(
50    api.subscribe,
51    api.getState,
52    api.getServerState || api.getInitialState,
53    selector,
54    equalityFn,
55  )
56  return slice
57}
58
59const createImpl = (createState) => {
60  const api =
61    typeof createState === 'function' ? createStore(createState) : createState
62
63  const useBoundStore = (selector, equalityFn) =>
64    useStore(api, selector, equalityFn)
65
66  Object.assign(useBoundStore, api)
67
68  return useBoundStore
69}
70
71export const create = ((createState) => createState ? createImpl(createState) : createImpl);

정말이지 이게 끝이다. 물론 기타 타입코드와 여러가지가 다른 코드가 존재하지만 핵심은 이것이다. 먼저 createStore의 코드를 살펴보자.

zustand는 Observer Pattern을 사용한다. 프론트엔드에서 빠질 수 없는 패턴이랴 여기서 listener 함수를 받는데 아래 listener 함수가 어디서 왔는지 서술하겠다.

1const setState = (partial, replace) => {
2  const nextState = 생략;
3  listeners.forEach((listener) => listener(state, previousState))
4}
5
6const getState = () => state
7
8const getInitialState = () => initialState
9
10const subscribe = (listener) => {
11  listeners.add(listener)
12  // Unsubscribe
13  return () => listeners.delete(listener)
14  }

처음 컴포넌트가 마운트 되었을 때 subscribe 함수가 호출된다. 그 이후에 상태를 업데이트 할 때 해당 파라미터가 콜백인지 아닌지 확인하고 다음 값을 갱신한 후에 리스너들을 순회하여 리스너 함수를 호출한다. 이렇게 api 객체를 만들어 useSyncExternalStoreWithSelector 의 파라미터로 넘긴다.

useSyncExternalStore#

react 18이 릴리즈 되면서 나온 훅이다. 해당 게시글에서 useSyncExternalStore 를 자세히 설명할 것은 아니고 해당 훅은 외부 스토어의 tearing 현상을 막기 위해 리액트 내부와 싱크를 맞추는 훅이다.

https://www.youtube.com/watch?v=KEDUqA9JeIo 위 영상에서 useSyncExternalStore를 사용하여 커스텀 상태관리를 만들고 있다.

useSyncExternalStore 은 첫번째 인자로 subscribe 함수를 받고 있다. 두번쨰 파라미터로는 해당 상태의 스냅샷을 받는 함수를 받고있다. 여기서 두번쨰 파라미터로 상태 객체를 전달하는게 아닌 함수 형태로 () => state 전달해야 하는데 이는 클로져의 특성을 활용하여 매번 변경되는 state를 반환하기 위함이다. 여기서 중요한 것은 저 listener 함수이다. listener 함수는 대체 어디서 넣어주는 것일까?

subscribe 함수에 console.log(listener.name) 를 출력해보면 다음과 같은 함수 이름이 나타난다.

handleStoreChange

handleStoreChange#

https://github.com/facebook/react/blob/f74c5ccf9469d3389ce3a1ee3b54988049e235f7/packages/use-sync-external-store/src/useSyncExternalStoreShimClient.js#L110

handleStoreChange 해당 함수는 여기서 주입하고 있었다. zustand는 react에서 제공하는 useState, useReducer 없이도 useSyncExternalStore 훅을 통하여 subscribe 함수 의존성을 받아서 리액트에서 상태를 관리해주고 있었디.

아래는 useSyncExternalStoreShimClient 의 일부를 가져온 것이다.

1const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});
2
3useEffect(() => {
4    // Check for changes right before subscribing. Subsequent changes will be
5    // detected in the subscription handler.
6    if (checkIfSnapshotChanged(inst)) {
7      // Force a re-render.
8      forceUpdate({inst});
9    }
10    const handleStoreChange = () => {
11      // TODO: Because there is no cross-renderer API for batching updates, it's
12      // up to the consumer of this library to wrap their subscription event
13      // with unstable_batchedUpdates. Should we try to detect when this isn't
14      // the case and print a warning in development?
15
16      // The store changed. Check if the snapshot changed since the last time we
17      // read from the store.
18      if (checkIfSnapshotChanged(inst)) {
19        // Force a re-render.
20        forceUpdate({inst}); <----------------------------------- 상태 업데이트 코드
21      }
22    };
23    // Subscribe to the store and return a clean-up function.
24    return subscribe(handleStoreChange);
25  }, [subscribe]);

직접 구현해보기#

이제 zustand를 참고하여 직접 커스텀 상태관리를 구현해보자. useSyncExternalStore 를 사용한다면 아주 간단한 상태관리를 구현할 수 있을 것이다. 상태 관리는 리액트에 위임하고 개발자는 그저 subscribe 함수와 상태 업데이트 시 리스너들을 호출해주면 될 것이다.

최종적으로 createStore 함수로 초기 상태값을 받고 반환받은 값을 컴포넌트의 훅형태로 쓸 수 있도록 할 것이다.

1import { useCallback, useSyncExternalStore } from 'react';
2
3const createStore = (initialState) => {
4  let state = initialState;
5
6  const getState = () => state;
7
8  const listeners = new Set();
9
10  const setState = (fn) => {
11    state = fn(state);
12    listeners.forEach((listener) => listener());
13  };
14
15  const subscribe = (listener) => {
16    listeners.add(listener);
17    return () => listeners.delete(listener);
18  };
19
20  return { getState, setState, subscribe };
21};
22
23
24const useStore = (store, selector) => {
25  const slice = useSyncExternalStore(
26    store.subscribe,
27    useCallback(() => selector(store.getState()), [store, selector])
28  );
29
30  return [slice, store.setState];
31};

간단하게 만들었다. zustand 로직보다는 간단하지만 결국 핵심 패턴은 옵저버 패턴을 이용한 useSyncExternalStore 훅 사용에 있다. 여기서 useSyncExternalStore 두번쨰 인자로 상태의 스냅샷을 사용하는 쪽에서 selector로 select할 수 있도록 했다. store와 selector를 의존성으로 받아 불필요한 리렌더링이 발생하지 않도록 useCallback 으로 감싸주었다.

잘 되는지 테스트를 작성해보자.

1test('형제간 상태 공유', () => {
2  const store = createStore({ count: 0 });
3
4  function Brother1() {
5    const [count, setState] = useStore(store, (state) => state.count);
6
7    return (
8      <React.Fragment>
9        <button
10          onClick={() => {
11            setState((prev) => ({ count: prev.count + 1 }));
12          }}
13        >
14          inc
15        </button>
16        <button
17          onClick={() => {
18            setState((prev) => ({ count: prev.count - 1 }));
19          }}
20        >
21          dec
22        </button>
23        <h1 data-testid="brother1">value: {count}</h1>
24      </React.Fragment>
25    );
26  }
27
28  function Brother2() {
29    const [count] = useStore(store, (state) => state.count);
30
31    return (
32      <React.Fragment>
33        <h1 data-testid="brother2">value: {count}</h1>
34      </React.Fragment>
35    );
36  }
37
38  function Parent() {
39    return (
40      <React.Fragment>
41        <Brother1 />
42        <Brother2 />
43      </React.Fragment>
44    );
45  }
46
47  const { getByText, getByTestId } = render(<Parent />);
48  fireEvent.click(getByText('inc'));
49  expect(getByTestId('brother1').textContent).toBe('value: 1');
50  expect(getByTestId('brother2').textContent).toBe('value: 1');
51
52  fireEvent.click(getByText('dec'));
53  expect(getByTestId('brother1').textContent).toBe('value: 0');
54  expect(getByTestId('brother2').textContent).toBe('value: 0');
55});

잘 통과된다. test

다음 포스트는 useSyncExternalStore 훅 이전 zustand3 버전에서는 어떻게 상태 관리를 하는지 알아보겠다.