ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • React Context API 잘 사용하기 (re-render 방지법 - selector & atom)
    React 2024. 7. 24. 23:13

    React의 Context API를 사용하면, Provider 하위의 어떤 컴포넌트에서나 함께 공유하는 데이터를 사용할 수 있는 장점이 있다.

    👻 그러나 Context API를 사용할 때 발생할 수 있는 문제 중 하나는 빈번한 리렌더링이다.

    이 re-render 문제 때문에 Context API는 종종 사용되는 것이 꺼려지는데, 어떻게 하면 이를 최적화하여 잘 사용할 수 있을지 알아보았다.

     

    내가 알아본 방법은 두 가지로, selector와 atom을 사용하는 두 가지 방법이다.

    타입스크립트로 작성한 예제와 함께 어떻게 하면 re-render 문제를 해결하여 Context API를 사용할 수 있을지 확인해보았다.

    Context API의 리렌더링 문제


    Context API를 사용하면 Provider로 데이터를 전달하고, 하위 컴포넌트에서 useContext로 해당 데이터를 쉽게 사용할 수 있다. 

    그러나 Provider의 값이 변경될 때마다 하위의 모든 컴포넌트가 리렌더링되는 문제가 발생할 수 있는 건 널리 알려진 문제로, 이로 인해 성능 저하가 발생할 수 있다 😭

    import React, {
      createContext,
      useContext,
      useState,
      useCallback,
      ReactNode,
    } from 'react';
    
    interface MyContextType {
      value: number;
      setValue: () => void;
    }
    
    const MyContext = createContext<MyContextType>({value: 0, setValue: () => {}});
    
    const MyProvider: React.FC<{children: ReactNode}> = ({children}) => {
      const [value, setValue] = useState(0);
    
      const increment = useCallback(() => {
        setValue(prev => prev + 1);
      }, []);
    
      return (
        <MyContext.Provider value={{value, setValue: increment}}>
          {children}
        </MyContext.Provider>
      );
    };
    
    const ChildComponent: React.FC = () => {
      const {value} = useContext(MyContext);
      console.log('ChildComponent rendered');
      return <div>{value}</div>;
    };
    
    const ButtonComponent: React.FC = () => {
      const {setValue} = useContext(MyContext);
      console.log('ButtonComponent rendered');
      return <button onClick={setValue}>Increment</button>;
    };
    
    const App: React.FC = () => (
      <MyProvider>
        <ChildComponent />
        <ButtonComponent />
      </MyProvider>
    );
    
    export default App;


    위 코드에서 버튼을 클릭하면 `value`이 증가하고, `value` 가 변경될 때마다 ChildComponent와 ButtonComponent가 각각 렌더링되는 과정을 콘솔에서 확인할 수 있다.

    버튼 클릭 시 ChildComponent 뿐만 아니라, ButtonComponent도 렌더링된다.

    ButtonComponent는 value값을 사용하지 않음에도, 리렌더링이 일어났다.

     

    이런 최적화 문제가 발생하기에, 어떻게 하면 이 문제를 해결할 수 있을지 Selector와 Atom을 사용하는 예제를 확인해보자.

     

    Selector를 사용한 리렌더링 방지


    Selector를 사용하면 Context의 일부 데이터만 구독할 수 있어 불필요한 리렌더링을 줄일 수 있다. 

    이를 위해 `useMemo`와 커스텀 훅을 사용할 수 있다.

     

    import React, {
      createContext,
      useContext,
      useState,
      useMemo,
      useCallback,
      ReactNode,
      memo,
    } from 'react';
    
    interface MyContextType {
      value: number;
      increment: () => void;
    }
    
    const MyContext = createContext<MyContextType>({
      value: 0,
      increment: () => {},
    });
    
    const MyProvider: React.FC<{children: ReactNode}> = ({children}) => {
      const [value, setValue] = useState(0);
    
      const increment = useCallback(() => {
        setValue(prev => prev + 1);
      }, []);
    
      const contextValue = useMemo(() => ({value, increment}), [value, increment]);
    
      return (
        <MyContext.Provider value={contextValue}>{children}</MyContext.Provider>
      );
    };
    
    // value값만 Select하는 함수
    const useValue = () => {
      const context = useContext(MyContext);
      if (context === undefined) {
        throw new Error('useValue must be used within a MyProvider');
      }
      return context.value;
    };
    
    const ChildComponent: React.FC = () => {
      const value = useValue();
      console.log('ChildComponent rendered');
      return <div>{value}</div>;
    };
    
    interface ButtonComponentProps {
      increment: () => void;
    }
    
    const ButtonComponent: React.FC<ButtonComponentProps> = memo(({increment}) => {
      console.log('ButtonComponent rendered');
      return <button onClick={increment}>Increment</button>;
    });
    
    // 렌더링할 APP
    const App: React.FC = () => (
      <MyProvider>
        <ChildComponent />
        <ButtonComponentWrapper />
      </MyProvider>
    );
    
    // ButtonComponent는 memo된 컴포넌트
    const ButtonComponentWrapper: React.FC = () => {
      const {increment} = useContext(MyContext);
      return <ButtonComponent increment={increment} />;
    };
    
    export default App;

     

    위 예제에서 `useValue` 훅은 `value`만 구독하므로 `setValue`가 변경될 때 불필요한 리렌더링을 방지할 수 있다.

    memo를 잘 활용하여 렌더링을 최적화할 수 있지만, 다소 구조가 복잡해지는 문제가 발생한다.

    또한, 적절하게 memo를 사용하는 건 어려운 일이다.

     

    Atom을 사용한 리렌더링 방지

    Atom을 사용하여 리렌더링을 방지하는 방법을 사용하면 각 상태를 독립적으로 관리하고, 필요한 컴포넌트만 리렌더링하도록 설정할 수 있다.

     

    다음과 같이 Atom과 관련된 타입과 훅을 정의할 수 있다.

    import React, {
      createContext,
      useContext,
      useState,
      useCallback,
      useEffect,
      ReactNode,
      ReactElement,
      memo,
    } from 'react';
    
    // Atom 인터페이스 정의
    interface Atom<T> {
      getValue: () => T;
      setValue: (value: T) => void;
      subscribe: (listener: () => void) => () => void;
    }
    
    // Atom 생성 함수 정의
    const createAtom = <T,>(initialValue: T): Atom<T> => {
      let value = initialValue;
      const listeners: Array<() => void> = [];
    
      const getValue = () => value;
    
      const setValue = (newValue: T) => {
        if (value !== newValue) {
          value = newValue;
          listeners.forEach(listener => listener());
        }
      };
    
      const subscribe = (listener: () => void) => {
        listeners.push(listener);
        return () => {
          const index = listeners.indexOf(listener);
          if (index !== -1) listeners.splice(index, 1);
        };
      };
    
      return {getValue, setValue, subscribe};
    };
    
    // Context 타입 정의
    interface MyContextType {
      valueAtom: Atom<number>;
      setValue: (value: number) => void;
    }
    
    // Context 생성
    const MyContext = createContext<MyContextType | undefined>(undefined);
    
    // Provider 컴포넌트 정의
    const MyProvider: React.FC<{children: ReactNode}> = ({children}) => {
      const [valueAtom] = useState(() => createAtom(0));
    
      const setValue = useCallback(
        (newValue: number) => {
          valueAtom.setValue(newValue);
        },
        [valueAtom],
      );
    
      return (
        <MyContext.Provider value={{valueAtom, setValue}}>
          {children}
        </MyContext.Provider>
      );
    };
    
    // Atom 훅 정의
    const useAtom = <T,>(atom: Atom<T>): [T, (value: T) => void] => {
      const [state, setState] = useState(atom.getValue);
    
      const setValue = useCallback(
        (newValue: T) => {
          atom.setValue(newValue);
        },
        [atom],
      );
    
      useEffect(() => {
        const unsubscribe = atom.subscribe(() => setState(atom.getValue()));
        return unsubscribe;
      }, [atom]);
    
      return [state, setValue];
    };
    
    // ChildComponent 정의
    const ChildComponent: React.FC = () => {
      const context = useContext(MyContext);
      if (!context) {
        throw new Error('ChildComponent must be used within a MyProvider');
      }
      const [value] = useAtom(context.valueAtom);
      console.log('ChildComponent rendered');
      return <div>{value}</div>;
    };
    
    // ButtonComponent 정의
    const ButtonComponent: React.FC = memo(() => {
      const context = useContext(MyContext);
      if (!context) {
        throw new Error('ButtonComponent must be used within a MyProvider');
      }
      const {setValue} = context;
    
      const increment = useCallback(() => {
        setValue(prev => prev + 1);
      }, [setValue]);
    
      console.log('ButtonComponent rendered');
      return <button onClick={increment}>Increment</button>;
    });
    
    // App 컴포넌트 정의
    const App: React.FC = (): ReactElement => (
      <MyProvider>
        <ChildComponent />
        <ButtonComponent />
      </MyProvider>
    );
    
    export default App;

     

    위 예제에서 우리는 createAtom 함수를 사용하여 Atom을 생성하고, useAtom 훅을 사용하여 Atom의 값을 구독하고 설정할 수 있다. 

    Atom은 동적으로 생성되기 때문에, 독립적이다.

    따라서 각 상태를 개별적으로 관리하고, 필요할 때마다 새 Atom을 생성하여 각 컴포넌트에서 사용할 수 있다.

     

     

    이를 통해 value가 변경될 때 필요한 부분만 리렌더링된다 😊

     

    jotai, zustand등 여러 상태 관리 라이브러리가 있어 편하지만, Context API를 사용하여 Provider로 데이터를 제공하는 것은 데이터 제공 영역을 제한하고, Provider에 각자 다른 props를 전달하여 여러군데서 재사용할 수 있는 장점이 있기 때문에, 최적화 단점을 고쳐 잘 사용하면 개발에 편의를 제공할 수 있다고 생각한다.

     

    🍇 jotai는 atom, redux는 selector를 사용한다.

Designed by Tistory.