-
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를 사용한다.
'React' 카테고리의 다른 글
React에서 XState 간단 사용 후기 😊 (0) 2025.02.13 React Compiler를 직접 사용해보기 🎶 (with. next 15) (1) 2024.12.10 React JSX의 List Rendering에서 key값 설정하기 (.map) (1) 2023.09.23 Server Component에서 Css-in-JS 라이브러리 사용하기 (feat. panda-css) (1) 2023.08.18 React Suspense와 React-Query, 그리고 react-error-boundary (0) 2023.08.14