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로 주입시켜 데이터를 전달 시킨다.
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#
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});
잘 통과된다.

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