카테고리 없음

[리액트] 모듈상태 / 구독패턴을 활용한 상태관리

봉이로그 2024. 4. 19. 04:55

모듈상태란?

모듈 상태의 엄격한 정의는 ES 모듈 스코프에 정의된 상수 또는 변수다.

여기서는 단순하게 모듈 상태는 전역적 이거나 파일의 스코프 내에서 정의된 변수라고 가정한다.

모듈상태의 예시코드이다. 리액트와는 관련이 없는 코드이다.

export const createStore = (initialState) => {
	let state = initialState;
	const getState = () => state;
	const setState = (nextState) => {
		state = typeof === 'function' ? nextState(state) : nextState;
	}
	return { getState, setState };
};

const { getState, setState } = createStore({ count: 0});

 

 

모듈상태와 리액트 컴포넌트의 상태가 일치하지 않는 경우

let count = 0;

const Component1 = () => {
  const [state, setState] = useState(count);
  const inc = () => {
    count += 1;
    setState(count);
  };
  return (
    <div>
      {state} <button onClick={inc}>+1</button>
    </div>
  );
};

const Component2 = () => {
  const [state, setState] = useState(count);
  const inc = () => {
    count += 2;
    setState(count);
  };
  return (
    <div>
      {state} <button onClick={inc}>+1</button>
    </div>
  );
};

 

모듈상태 count가 변경되어도 컴포넌트1과 컴포넌트2의 상태는 일치하지 않게 된다.

이러한 문제를 해결하는 방법중 하나는 컴포넌트 외부에 있는 모듈수준에서 setState를 관리하는것이다.

컴포넌트 생명 주기에 따라 동작하는 useEffect 훅을 이용해 setState를 리액트 외부에 Set과 같은 별도의 자료구조에 추가해서 관리하게 할 수 있다.

let count = 0;
const setStateFunctions = new Set<(count: number)=> void>();

const Component1 = () => {
  const [state, setState] = useState(count);
  useEffect(()=>{
	  setStateFunctions.add(setState);
	  return ()=> {setStateFunctions.remove(setState); };
  },[])
  const inc = () => {
    count += 1;
    setStateFunctions.forEach((fn)=> {
	    fn(count)
    });
  };
  return (
    <div>
      {state} <button onClick={inc}>+1</button>
    </div>
  );
};

const Component2 = () => {
  const [state, setState] = useState(count);
  useEffect(()=>{
	  setStateFunctions.add(setState);
	  return ()=> {setStateFunctions.remove(setState); };
  },[])
  const inc = () => {
    count += 2;
    setStateFunctions.forEach((fn)=> {
	    fn(count)
    });
  };
  return (
    <div>
      {state} <button onClick={inc}>+1</button>
    </div>
  );
};

 

증가함수에서 setState를 사용하지 않고 setStateFunctions에 추가한 setState를 사용하게 변경되었다.

이 방법도 좋은 해결책은 아니다. Component1과 Component2에 중복코드가 있고 추가적으로 계속 늘어날것이다.

 

모듈상태에 구독패턴을 추가하여 관리하기

type Store<T> = {
  getState: () => T;
  setState: (action: T | ((prev: T) => T)) => void;
  subscribe: (callback: () => void) => () => void;
};

// 초기 상태 값을 통해 store를 생성하는 함수
const createStore = <T extends unknown>(initialState: T): Store<T> => {
  let state = initialState;
  const callbacks = new Set<() => void>();
  const getState = () => state;
  const setState = (nextState: T | ((prev: T) => T)) => {
    state =
      typeof nextState === "function"
        ? (nextState as (prev: T) => T)(state)
        : nextState;
    callbacks.forEach((callback) => callback());
  };
  
  const subscribe = (callback: () => void) => {
    callbacks.add(callback);
    return () => {
      callbacks.delete(callback);
    };
  };
  return { getState, setState, subscribe };
};

 

모듈상태를 생성하는 createStore 함수가 있다. 함수내부적으로 callbacks는 subscribe구독함수가 호출될때 인자로 넘어온 콜백(callback) 함수들을 추가하게 되고, setState를 이용하여 상태를 변경할때 넘어온 콜백 함수들을 호출하게 된다.

일반적인 구독/발행 패턴의 모습이다.

 

const useStore = <T extends unknown>(store: Store<T>) => {
  const [state, setState] = useState(store.getState());
  
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.getState()); // 렌더링 시 create스토어의 state를 구독한다
    });
    setState(store.getState()); // [1]
    return unsubscribe;  
  }, [store]);
  
  return [state, store.setState] as const;
};

 

[1]은 에지케이스이다. useEffect가 뒤늦게 실행돼서 store가 이미 새로운상태를 가지고 있을 가능성때문에 호출한다.

const store = createStore({ count: 0 });

const Component1 = () => {
  const [state, setState] = useStore(store);
  const inc = () => {
    setState((prev) => ({
      ...prev,
      count: prev.count + 1,
    }));
  };
  return (
    <div>
      {state.count} <button onClick={inc}>+1</button>
    </div>
  );
};

const Component2 = () => {
  const [state, setState] = useStore(store);
  const inc2 = () => {
    setState((prev) => ({
      ...prev,
      count: prev.count + 2,
    }));
  };
  return (
    <div>
      {state.count} <button onClick={inc2}>+2</button>
    </div>
  );
};

const App = () => (
  <>
    <Component1 />
    <Component2 />
  </>
);

export default App;

 

모듈 상태는 결국 React에서 갱신되기 때문에 React의 상태와 동일하게 모듈 상태를 불변적으로 갱신하는 것이 중요하다.

이제 Component1과 Component2는 store라는 모듈 상태를 전역으로 사용하게 되었다.

 

Component1과 Component2가 store에서 생성된 객체의 하위요소들을 각각 사용한다고 가정할때 상태의 일부분만 반환하는 선택자(Selector)를 도입할 수 있다.

const useStoreSelector = <T, S>(store: Store<T>, selector: (state: T) => S) => {
  const [state, setState] = useState(() => selector(store.getState()));
  
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(selector(store.getState()));
    });
    setState(selector(store.getState()));
    return unsubscribe;
  }, [store, selector]);
  
  return state;
};

 

const store = createStore({ count1: 0, count2: 0 });

const Component1 = () => {
  const state = useStoreSelector(
    store,
    useCallback((state) => state.count1, [])
  );
  const inc = () => {
    store.setState((prev) => ({
      ...prev,
      count1: prev.count1 + 1,
    }));
  };
  return (
    <div>
      count1: {state} <button onClick={inc}>+1</button>
    </div>
  );
};

// selector에 useCallback을 래핑하고싶지 않을 경우 외부에 정의
const selectCount2 = (state: ReturnType<typeof store.getState>) => state.count2;

const Component2 = () => {
  const state = useStoreSelector(store, selectCount2);
  const inc = () => {
    store.setState((prev) => ({
      ...prev,
      count2: prev.count2 + 1,
    }));
  };
  return (
    <div>
      count2: {state} <button onClick={inc}>+1</button>
    </div>
  );
};

const App = () => (
  <>
    <Component1 />
    <Component1 />
    <Component2 />
    <Component2 />
  </>
);

 

Component1에서 Selector함수에 useCallback을 사용하지 않으면 useStoreSelector의 useEffect 두번째 인수에 선택자가 지정돼 있으므로 선택자 함수를 바꾸지 않더라도 Component1을 렌더링할 때마다 store를 구독 해제하고 구독하는 것을 반복하게 된다.

 

useStoreSelector훅은 store 또는 selector가 변경될때 주의할 점이 있다. useEffect는 조금 늦게 실행되기 때문에 재구독 될때까지는 갱신되기 이전 상태 값을 반환한다.

 

useSubscription hooks를 이 문제를 해결할수 있다.

useSubscription 사용한 useStoreSelector

// useSubscription을 사용한 useStoreSelector
const useStoreSelector = <T, S>(store: Store<T>, selector: (state: T) => S) =>
  useSubscription(
    useMemo(
      () => ({
        getCurrentValue: () => selector(store.getState()),
        subscribe: store.subscribe,
      }),
      [store, selector]
    )
  );

// 기존의 useStoreSelector
const useStoreSelector = <T, S>(store: Store<T>, selector: (state: T) => S) => {
  const [state, setState] = useState(() => selector(store.getState()));
  
  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(selector(store.getState()));
    });
    setState(selector(store.getState()));
    return unsubscribe;
  }, [store, selector]);
  
  return state;
};

 

useSubscription hooks는 React18에서 useSyncExternalStore로 대체되었다.

 

결론적으로 모듈 상태와 구독 패턴을 이용해 React에서 전역상태를 관리하고 선택자(selector)를 이용해 리렌더링을 최소화했다.