React

React Context API 잘 사용하기 (re-render 방지법 - selector & atom)

citron031 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를 사용한다.