React Context API 잘 사용하기 (re-render 방지법 - selector & atom)
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를 사용한다.