라이브러리 없이 선택적 구독 구현하기

ChoiYongHyeun·2025년 1월 18일
0

리액트

목록 보기
32/37
post-thumbnail

Context 를 이용하여 객체를 상태로 관리 할 때의 문제점

  interface ChromeStorage {
    reference: ReferenceData[];
    autoConverting: boolean;
    isDarkMode: boolean;
    isContentScriptEnabled: boolean;
    isUnAttachedReferenceVisible: boolean;
  }

진행하던 사이드 프로젝트에서 최대한 라이브러리를 사용하지 않고 해보고 싶어서 위 타입의 상태를 React.Context 를 이용하여

전역 상태 관리를 시행하고 있었다.

const ChromeStorageContext = createContext<{
  chromeStorage: ChromeStorage;
  setChromeStorage: (
    updater: (prevStorage: ChromeStorage) => ChromeStorage
  ) => Promise<void>;
} | null>(null);

export const ChromeStorageProvider = ({
  children,
}: {
  children: React.ReactNode;
}) => {
  const [chromeStorage, _syncChromeStorage] = useState<ChromeStorage>(
    chromeStorageInitialValue
  );

  ... 
  
  return (
    <ChromeStorageContext.Provider
      value={{
        chromeStorage,
        setChromeStorage,
      }}
    >
      {children}
    </ChromeStorageContext.Provider>
  );
};

export const useChromeStorage = () => {
  const context = useContext(ChromeStorageContext)!;
  return context;
};

객체 형태의 상태를 Context 로 내려주는 것은 그다지 효율적인 방법은 아니다.

사실 React.Contextprops drilling 없이 특정 value 값을 내려주는 역할만 할 뿐

효과적으로 상태를 관리하기 위해 만들어진 메소드가 아니기 때문이다.

State 는 immutable

객체 형태의 state 를 setState 를 통해 업데이트 하게 된다면 새로운 객체가 상태로 선언된다.

이는 리액트에서 state 는 불변하게 사용하기 때문인데 이러한 문제로

상태의 일부 값만 사용하는 컴포넌트는 본인이 사용하지 않는 값이 업데이트 되더라도

구독 하고 있는 상태(객체) 자체가 새롭게 만들어지기 때문에 리렌더링이 일어나게 된다.

export const AutoConvertingToggle = () => {
  const {
    chromeStorage: { autoConverting, isContentScriptEnabled },
    setChromeStorage,
  } = useChromeStorage();
  ...

위 컴포넌트는 실제 chromeStorage.autoConverting 이라는 boolean 원시 값만 이용하고 있지만

다른 컴포넌트에서 setChromeStorage 를 통해 chromeStorage 상태가 업데이트 된다면

chromeStorage.autoConverting 값 변환 유무에 상관 없이 chromeStorage 객체 자체가 새롭게 생성되어 리렌더링이 일어나게 된다.

Context에서 이 문제를 해결하기 위해선 ?

사진 출처 : Escape React Context Hell. React’s Context API is a handy tool for… | by Ambrose kibet | Medium

만약 그렇게 사용하고 싶지 않다면 React.Context 를 통해 내려주는 값이

객체가 아닌 특정 값 하나만이여야 할 것이다.

그렇다면 본인이 구독하고 있는 상태 외의 것이 수정 되더라도 본인이 구독하고 있지 않기 때문에 리렌더링이 일어나지 않는다.

근데 이게 효율적인가 생각해본다면 그렇지 않을 것이다.

위 코드만 본다 해도 특정 상태 하나가 늘어날 떄 컨텍스트도 하나 더 추가해줘야 할 것이고

만약 useContext 자체를 커스텀 훅으로 만들어 사용한다면 커스텀 훅도 하나 더 만들어줘야 할 것이다.

선택적 구독이 필요하다

위에서 말한 문제인 본인이 사용하고 있는 상태의 업데이트 일 때에만 리렌더링이 일어나길 원한다

선택적 구독을 지원하는 라이브러리를 사용하는 것이 가장 최선이다.

가장 대표적인 예시가 zustand 일텐데 (사실 내가 reduxzustand 밖에 사용해보지 않았다)

zustand 에선 다음과 같이 선택적 구독을 지원한다.

import { create } from 'zustand'

const useStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
  updateBears: (newBears) => set({ bears: newBears }),
}))

function BearCounter() {
  const bears = useStore((state) => state.bears)
  return <h1>{bears} around here...</h1>
}

function Controls() {
  const increasePopulation = useStore((state) => state.increasePopulation)
  return <button onClick={increasePopulation}>one up</button>
}

zustand 의 가장 큰 특징 두 가지를 따라 한 번 구현 해보자

  • 선택적 구독을 통한 렌더링 최적화
  • 클로저를 통한 상태 관리로 Context 없이 전역상태 관리 지원

createStore 직접 구현

import { useEffect, useState } from "react";

type Selector<T, R> = (state: T) => R;

export const createStore = <Store extends object>(
  initialState: Store | (() => Store)
) => {
  let store =
    typeof initialState === "function" ? initialState() : initialState;

  const callbacks = new Set<() => void>();

  const subscribe = (callback: () => void): (() => void) => {
    callbacks.add(callback);
    return () => callbacks.delete(callback);
  };

  const getState = () => ({ ...store });

  const setState = (
    action: Partial<Store> | ((state: Store) => Partial<Store>)
  ) => {
    const newState =
      typeof action === "function" ? action({ ...store }) : action;

    store = {
      ...store,
      ...newState,
    };

    callbacks.forEach((callback) => callback());
  };

  const useStore = <R>(selector: Selector<Store, R>) => {
    const [state, _setState] = useState(() => selector(store));

    useEffect(() => {
      const unsubscribe = subscribe(() => {
        _setState(selector(store));
      });

      return unsubscribe;
    }, []);

    return state;
  };

  useStore.setState = setState;
  useStore.getState = getState;

  return useStore;
};

createStore 메소드가 선언 될 때 생성 된 store 객체를 클로저 형태로 바라보는 useStore 커스텀 훅을 이용해 zustand 의 사용 예시와 유사하게 선택적 구독을 구현 할 수 있다.

export const chromeStorageInitialValue: ChromeStorage = {
  reference: [],
  autoConverting: false,
  isDarkMode: false,
  isContentScriptEnabled: false,
  isUnAttachedReferenceVisible: true,
};

export const useChromeStorage = createStore(chromeStorageInitialValue);

export const ConvertToReferenceButton = () => {
  const isContentScriptEnabled = useChromeStorage(
    (state) => state.isContentScriptEnabled
  );
  ...
 }

컴포넌트에서 use ... 를 통해 store (이하 globalState) 객체의 특정 값을 바라보는 state (이하 localState) 를 이용하여

컴포넌트들과 globalState 를 공유한 채로 리렌더링을 유발 할 localState 만을 선택적으로 구독 하는 것이 가능하다.

선택적 구독이 왜 가능할까 ?


export const createStore = <Store extends object>(
  initialState: Store | (() => Store)
) => {
  let store =
    typeof initialState === "function" ? initialState() : initialState;    
          
  ...
  
  
  const useStore = <R>(selector: Selector<Store, R>) => {
    const [state, _setState] = useState(() => selector(store));

    useEffect(() => {
      const unsubscribe = subscribe(() => {
        _setState(selector(store));
      });

      return unsubscribe;
    }, []);

    return state;
  };

  useStore.setState = setState;
  useStore.getState = getState;

  return useStore;
};

코드를 조금만 자세히 들여다보면 setState 를 통해 globalState 값이 변경 되고 나면

subscribe 메소드로 인해 추가 된 콜백 메소드인 _setState(selector(store)) 가 매 번 호출 되는 것이다.

이때 {...} 형태의 globalState 가 변경이 일어나 새로운 객체인 globalState 로 만들어진다 하더라도

컴포넌트가 실제 구독 중인 localState 의 상태 변화 (_setState) 가 비교하는 주체는

globalState 자체가 아닌 selector(globalState) (state => state.something) 이기 때문에

selector(globalState) 값만 변하지 않았다면 리렌더링 유발되지 않는다.

구독하고 있는 값이 객체더라도 선택적 구독은 유효하다

만약 selector(globalState) 를 통해 구독 하고 있는 값이 문자나 숫자 같은 원시 값이 아닌 객체더라도 선택적 구독은 여전히 잘 작동한다.

globalState 가 새롭게 선언되더라도 내부에 존재하는 객체들은 메모리 주소에 의한 참조를 하고 있기 때문에

_setState( obj ) 가 일어나더라도 비교 과정에서 이전 상태와 같은 객체로 판단되어 리렌더링이 일어나지 않는다.

출처

  1. [React] React로 상태관리 라이브러리 구현

사실 웬만한 코드는 위 코드를 기반으로 해서 만들어졌다고 볼 수 있다.

아주 좋은글이다.

위 코드에서 Context 없이 createStore 가 커스텀 훅을 반환하도록 하여 실제 zustand 를 사용 할 때와 동일한 형태로 사용 하도록 수정된 버전에 가깝다.

PS

state 자체를 일반 메소드 안에서 호출하는 것이 가능한지 처음 알았다.

심지어 굳이 use .. 로 시작하지 않고
return ()=>{ const state = useState(...) } 처럼 해도 되더라!

이번 프로젝트에서 폼 컴포넌트를 선택적 구독 때문에 zustand 를 사용했다가 리액트 훅 폼으로 마이그레이션 했었는데

선택적 구독을 위처럼 구현 할 수 있다면 복잡한 상태 관리도 비슷한 로직을 이용하여 구현 할 수 있지 않을까 ? 라는 생각이 든다.

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글

관련 채용 정보