최근 Context API와 Recoil을 비교하면서 위 주제에 대해 이야기하게 되었다. 이에 대한 의견이 나뉘어서 이에 대해 직접 확인해 보는 시간을 가졌다. 먼저 Context의 개념을 알아보고, 코드를 보면서 상태 변화에 따른 렌더링이 어떻게 발생하는지 알아보자. 그 이후에 useMemo, memo 등을 적용해 보면서 최적화하고, Context가 상태 관리 라이브러리를 대체할 수 있는지 생각해 보자.
Context 알아보기
Context란
- props drilling: 상위 컴포넌트에서 제공하는 데이터를 하위 컴포넌트로 필요한 위치까지 계속해서 넘기는 것
- Context API는 이를 극복하기 위해 등장한 개념이다.
- 명시적 props 전달 없이도 하위 컴포넌트 모두에서 자유롭게 원하는 값을 사용할 수 있다.
useContext
- 상위 컴포넌트에서 만들어진 Context를 함수형 컴포넌트에서 사용할 수 있도록 만들어진 훅
- Provider에 의존성을 가지게 되므로 재활용하기 어려운 컴포넌트가 되므로 useContext를 사용하는 컴포넌트를 최대한 작게 하거나 재사용되지 않을 만한 컴포넌트에서 사용해야 한다.
- Context가 미치는 범위는 최대한 좁게 설정해야 한다.
- Context는 상태 관리 라이브러리가 아니라, 상태를 주입해 주는 API다.
- 컴포넌트 실행 시 Context가 존재하지 않아 발생하는 에러를 방지하기 위해 별도의 함수로 감싸서 사용하는 것이 좋다
function useMyContext() {
const context = useContext(MyContext)
if (context === undefined) {
throw new Error('useMyContext는 ContextProvider 내부에서만 사용할 수 있습니다.')
}
// context가 존재하는 경우에만 사용 가능하도록
return context
}
Context API와 값 업데이트
Context API는 리액트가 상태를 비교하여 업데이트하는 방식인 Object.is로 이전 상태값과 다음 상태값이 다른 경우 업데이트한다.
Context API의 렌더링
Provider로 감싼 하위 컴포넌트가 모두 렌더링 된다는 오해를 불러일으키는 코드를 먼저 보자. 아래 블로그에서는 Context를 사용하는 컴포넌트를 렌더링 했을 뿐인데 Context를 사용하지 않는 ChildComponentOne에서도 리렌더링이 발생한다는 점을 Context API의 렌더링 이슈로 언급하고 있다. 참고로 useEffect에서 로그를 찍는 코드는 생략하였다.
(참고 자료) Context API는 왜 쓰고, 그렇다면 Redux는 필요 없을까?
const ChildComponentOne = () => {
console.log("Child Component 1 Rendered");
return <ChildComponentTwo />;
};
const ChildComponentTwo = () => {
const [number, setNumber] = useContext(MyContext);
console.log("Child Component 2 Rendered");
return <>
<p>{number}</p>
<button onClick={() => setNumber(prev => prev + 1)}>+</button>
</>
};
export default function App() {
const [number, setNumber] = useState(0);
return (
<MyContext.Provider value={[number, setNumber]}>
<ChildComponentOne />
</MyContext.Provider>
);
}
실제로 코드를 동작시켜 보면 아래처럼 ChildComponentTwo에서 Context의 value를 변경하는데도 아무런 상관 없는 것처럼 보이는 ChildComponentOne도 함께 렌더링 되는 것을 볼 수 있다.
하지만 잘 살펴보면, 이는 Context 때문이 아니라 <App /> 컴포넌트 내의 상태가 변경되기 때문이다. 리액트에서 부모 컴포넌트의 상태값이 변경되면 하위의 모든 자식 컴포넌트는 모두 리렌더링 된다는 점을 생각해 보면 자연스러운 일이다.
따라서 부모 컴포넌트의 상태를 Provider의 value로 넘겨줄 경우, 위처럼 의도하지 않은 컴포넌트의 상태 변경을 유발할 수 있다. 그렇다면 어떻게 하면 원래 의도대로 Context를 구독하고 있는 컴포넌트만 렌더링 되도록 할 수 있을까? 상태를 부모가 가지지 않고 Provider가 가지도록 분리하면 된다.
type MyContextType = [number, React.Dispatch<React.SetStateAction<number>>];
const MyContext = createContext<MyContextType | undefined>(undefined);
// useMyContext를 항상 Provider 내부에서만 쓸 수 있도록 감싸주었다.
function useMyContext() {
const value = useContext(MyContext);
if (value === undefined) {
throw new Error('useMyContext should be used within MyContextProvider');
}
return value;
}
const ChildComponentOne = () => {
return <ChildComponentTwo />;
};
const ChildComponentTwo = () => {
const [number, setNumber] = useMyContext();
return (
<>
<p>{number}</p>
<button onClick={() => setNumber((prev: number) => prev + 1)}>+</button>
<button onClick={() => setNumber((prev: number) => prev - 1)}>-</button>
</>
);
};
function MyContextProvider({ children }: PropsWithChildren) {
// 상태를 Provider 내부로 분리
const state = useState(0);
return <MyContext.Provider value={state}>{children}</MyContext.Provider>;
}
export default function App() {
return (
<MyContextProvider>
<ChildComponentOne />
</MyContextProvider>
);
}
ChildComponentTwo 버튼을 눌러 Provider의 value를 변경했을 때 ChildComponentTwo의 로그만 찍히는 것을 확인할 수 있다. react-dev-tools가 반짝이는 것을 볼 수 있는데, 이는 사실상 memo를 해도 렌더링 된다고 표시되는 버그(링크)가 이슈에 아직 열려 있기 때문에 실제 로그를 찍어보는 것이 더 정확할 것으로 보인다.
정리
결론적으로 Context API는 Provider 하위에서 해당 Context를 구독하고 있는 컴포넌트만 리렌더 한다. Context API의 목적은 의존성 주입의 형태로 props drilling을 해결하는 것이다.
Context API 최적화하기
Context API를 최적화할 때는 두 가지만 기억하면 된다.
- Provider로 넘겨주는 value가 객체(함수, 배열 등)인 경우 useMemo, useCallback으로 메모이제이션하여 매 렌더링 시 새로운 객체를 생성하지 않도록 한다.
const CounterProvider = ({ children }) => {
const [counter, setCounter] = useState(1);
// setter 함수 메모이제이션
const actions = useMemo(
() => ({
increase() {
setCounter(prev => prev + 1);
},
decrease() {
setCounter(prev => prev - 1);
},
}),
[],
);
// Provider로 주입하는 상태값인 배열 메모이제이션
const value = useMemo(() => [counter, actions], [counter, actions]);
return <CounterContext.Provider value={value}>{children}</CounterContext.Provider>;
};
- 부모 컴포넌트가 리렌더링 될 때 Context를 구독하고 있는 하위 컴포넌트를 리렌더링 하지 않는 것이 목표라면 React.memo로 컴포넌트를 메모이제이션한다.
// Child 컴포넌트 메모이제이션
const ChildMemo = memo(Child);
const App = () =>
const [appCounter, setAppCounter] = useState(0);
return (
<CounterProvider>
<div className="container">
<h2>Parent</h2>
<button type="button" onClick={() => setAppCounter(appCounter + 1)}>
{appCounter}
</button>
<ChildMemo />
</div>
</CounterProvider>
);
};
배열이나 함수 같은 객체는 매 렌더링 시마다 새롭게 생성되므로 메모이제이션해주는 게 좋다. 하지만 메모이제이션은 항상 비용이 들기 때문에 어떤 상황에 이득이 되는지를 생각해 보면 좋다.
- 현재 코드에서 Provider는 상위 컴포넌트가 리렌더링 되거나 Provider의 Context가 변경되는 경우 리렌더링 된다.
- actions를 메모이제이션하면 두 상황 모두에 초기 렌더링의 참조를 유지한다.
- providerValue는 상위 컴포넌트가 리렌더링 되는 경우 참조를 유지한다. providerValue는 전역 상태인 counter와 actions에 의존하기 때문에 Context를 변경할 시에는 재생성하는 것이 맞다. actions는 이미 메모이제이션해두었기 때문에 counter가 변경될 때 재생성될 것이다.
좀 더 확실히 알기 위해 Provider 내부에 로그를 찍어보자. 각 useEffect의 의존성 배열을 메모의 의존성과 같게 하여 재생성되는 시점에 찍히도록 했다.
// Provider 컴포넌트 내부
useEffect(() => {
console.log("provider rendering");
});
useEffect(() => {
console.log("actions recreated");
}, [actions]);
useEffect(() => {
console.log("providerValue recreated");
}, [providerValue]);
- Parent와 Child는 각각 지역 상태를 가지고, GrandChild는 Context를 사용한다.
- GrandChild에서 Context의 값을 변경할 때, Context를 사용하는 GrandChild 컴포넌트와 Provider가 리렌더 된다. 그리고 의존성인 counter가 변경되었으므로 providerValue만 재생성되며, actions는 메모이제이션되어 유지된다.
- 최상위 컴포넌트 Parent의 버튼을 눌러 상태를 변경할 때, Child 컴포넌트가 메모이제이션되어 있어 리렌더링 되지 않는다. Provider는 렌더링 되지만 actions도 providerValue도 재생성되지 않는 것을 볼 수 있다.
최적화를 한 최종 코드
// import...
const CounterContext = createContext();
const CounterProvider = ({ children }: PropsWithChildren) => {
const [counter, setCounter] = useState(0);
// 객체 메모이제이션
const actions = useMemo(function memoActions() {
return {
increase() {
setCounter((prev) => prev + 1);
},
decrease() {
setCounter((prev) => prev - 1);
},
};
}, []);
// 넘겨줄 값(배열) 메모이제이션
const providerValue = useMemo(() => [counter, actions], [counter, actions]);
return <CounterContext.Provider value={providerValue}>{children}</CounterContext.Provider>;
};
function useCounter() {
const value = useContext(CounterContext);
if (value === undefined) {
throw new Error("useCounterState should be used within CounterProvider");
}
return value;
}
const GrandChild = () => {
const [counter, actions] = useCounter();
return (
<div className="container">
<h2>Grand Child</h2>
<button type="button" onClick={() => actions.increase()}>
{counter}
</button>
</div>
);
};
const Child = () => {
const [childState, setChildState] = useState(0);
return (
<div className="container">
<h2>Child</h2>
<button type="button" onClick={() => setChildState(childState + 1)}>
{childState}
</button>
<GrandChild />
</div>
);
};
// Child 컴포넌트 메모이제이션
const ChildMemo = memo(Child);
const App = () => {
const [appCounter, setAppCounter] = useState(0);
return (
<CounterProvider>
<div className="container">
<h2>Parent</h2>
<button type="button" onClick={() => setAppCounter(appCounter + 1)}>
{appCounter}
</button>
<ChildMemo />
</div>
</CounterProvider>
);
};
export default App;
- 부모 컴포넌트의 상태가 변경되면 자식 컴포넌트가 Context를 사용하는 것과 무관하게 리렌더링 되는 것은 자연스러운 일이다. 이를 막고 싶다면 React.memo로 하위 컴포넌트를 메모이제이션한다.
- Provider로 전달하는 값이 객체(배열, 함수, 객체 등)인 경우, 매 렌더링마다 재생성되는 것을 막고 싶다면 useMemo나 useCallback을 사용해 메모이제이션한다.
Context API를 넘어 상태 관리 라이브러리가 필요한 이유
모던 React Deep Dive에서 언급하고 있는 것처럼 상태 관리 라이브러리가 되기 위한 조건은 아래와 같다.
- 어떠한 상태를 기반으로 다른 상태를 만들어 낼 수 있어야 한다.
- 필요에 따라 이러한 상태 변화를 최적화할 수 있어야 한다.
Context의 경우, 위의 어떠한 것도 수행하지 못한다. Redux, Zustand, Jotai, Recoil과 같은 상태 관리 라이브러리의 경우 위의 두 가지를 조건을 만족한다. 예를 들어, Recoil은 atom과 selector를 통해 각각 상태와 파생 상태를 관리하며, 이 selector는 파생 상태를 캐싱하여 필요할 경우에만 재계산한다. 또한 리렌더를 최소화하기 위해 업데이트를 묶어서 처리하고, 변경된 atom을 구독하고 있는 컴포넌트만 리렌더 되도록 하는 구조로 상태 변화를 최적화한다.
Context API를 사용하면 상태가 변경될 때 해당 Context를 구독하고 있는 모든 컴포넌트가 리렌더링 된다. 이때 부분적으로 파생상태를 만들 수 없기 때문에 Provider를 일일이 분리해야 하는 불편함이 생긴다.
Context의 value가 객체인 경우를 생각해 보자. 객체 내부의 상태 하나만 변경하면 어떻게 될까? 이를 직접적으로 사용하지 않더라도 Context를 구독하고 있다면 리렌더링 된다. 이를 방지하기 위해서는 각 상태를 다른 Provider로 분리해 Context 변경 시 value를 사용하고 있는 컴포넌트만 리렌더링 되도록 해야 한다.
const CounterValueContext = createContext();
const CounterActionsContext = createContext();
const CounterProvider = ({ children }) => {
const [counter, setCounter] = useState(1);
const actions = useMemo(
() => ({
increase() {
setCounter(prev => prev + 1);
},
decrease() {
setCounter(prev => prev - 1);
},
}),
[],
);
// 각 value에 대한 Provider를 분리
return (
<CounterActionsContext.Provider value={actions}>
<CounterValueContext.Provider value={counter}>{children}</CounterValueContext.Provider>
</CounterActionsContext.Provider>
);
};
// 이를 사용하는 훅도 분리
function useCounterValue() {
const value = useContext(CounterValueContext);
if (value === undefined) {
throw new Error('useCounterValue should be used within CounterProvider');
}
return value;
}
function useCounterActions() {
const value = useContext(CounterActionsContext);
if (value === undefined) {
throw new Error('useCounterActions should be used within CounterProvider');
}
return value;
}
const App = () => {
return (
<CounterProvider>
<Value />
<Buttons />
</CounterProvider>
);
};
const Value = () => {
// value가 변경될 때만 리렌더
const counter = useCounterValue();
return <h1>{counter}</h1>;
};
const Buttons = () => {
// actions가 변경될 때만 리렌더
const actions = useCounterActions();
return (
<>
<button type="button" onClick={actions.increase}>
+
</button>
<button type="button" onClick={actions.decrease}>
-
</button>
</>
);
};
추가적으로 React 팀에서 실험적 버전으로 Context selector에 대한 PR이 있기는 했지만 현재 우선순위는 아닌 듯하다. (관련 이슈)
또한 React Context가 "상태 관리" 도구가 아닌 이유, 그리고 Redux를 대체하지 않는 이유에 따르면, 상태 관리는 아래와 같은 것들을 수행할 수 있어야 한다.
- 초기값 저장
- 현재 값 읽기
- 값 업데이트
가장 간단하게 생각해 볼 수 있는 것이 useState나 useReducer의 동작이다. 두 훅 모두 호출하여 초기 값을 저장하고, 현재 상태 값을 읽을 수 있다. 값을 업데이트하기 위해서 setState 또는 dispatch를 사용할 수 있으며, 컴포넌트가 리렌더 되므로 값이 변경되었음을 알 수 있다. 이와 유사하게 Redux는 store.getState()로 상태값을 읽을 수 있고, store.dispath(action)으로 업데이트가 가능하다.
하지만 Context API는 이를 수행할 수 없다. 위의 예에서 값을 읽고, 업데이트하지 않았나?라는 질문을 할 수 있다. 하지만 잘 생각해 보면 이 값을 유지하고 업데이트하기 위해 useState를 사용했음을 알 수 있다. Context 객체 자체는 업데이트를 하기 위한 함수를 가지고 있지 않으며, 스스로는 아무것도 저장하지 않는다. 이는 앞서 언급한 대로 Context API의 목적이 단순히 Provider의 값을 구독하고 있는 컴포넌트에 전달하는 것이기 때문이다.
Context API vs. Redux
- Context API는 언제 쓰면 될까?
- 컴포넌트를 통해 props를 하위로 전달하지 않고 리액트 컴포넌트 트리 일부분에 값을 제공하고 싶을 때
- 업데이트가 빈번하지 않은 경우: locale, theme, configuration
- Context API 대신 Redux를 써야 하는 경우는?
- 애플리케이션의 많은 컴포넌트에서 사용되는 상태가 많은 경우
- 상태가 시간이 지남에 따라 자주 업데이트 되는 경우(링크)
- 상태를 업데이트하는 로직이 복잡한 경우
- 여러 사람이 작업하는 중간 이상 크기의 코드 베이스인 경우
- 언제, 왜, 어떻게 상태가 변경되었는지 이해하고 싶은 경우
정리
- Context API는 데이터를 전달하는 props 이외의 방식으로, 상태 관리 라이브러리를 대체하지 않는다.
- 그 이유는 Context API가 상태 관리를 할 수 없기 때문이다. 즉, 기존 상태 기반의 파생 상태를 만들거나 상태 변화를 최적화할 수 없다. 또한, 자체적으로는 초기값 저장, 현재값 읽기, 값 업데이트를 할 수 없다.
- 이 과정에서 Provider를 분리하고, React.memo, useMemo, 그리고 useCallback을 이용해 Context를 사용하고 있는 컴포넌트만 업데이트하도록 최적화할 수 있지만, 상태가 많아질수록 관리가 어렵다. 따라서 상태 업데이트가 빈번하지 않거나 애플리케이션의 규모가 작은 경우, 그리고 상태를 사용하는 범위가 좁은 경우 사용할 수 있다.
REF
[React Deep Dive] React hooks 파헤치기
다른 사람들이 안 알려주는 리액트에서 Context API 잘 쓰는 방법
'FrontEnd > React' 카테고리의 다른 글
크롬 DevTools 리로드 문제 해결하기([Error] Looks like this page doesn't have React, or it hasn't been loaded yet.) (18) | 2024.03.25 |
---|---|
TanStack Query를 활용하여 Route 기반 Prefetching하기(feat.Render-as-you-fetch) (25) | 2024.02.27 |
[React Query] React Query에서 ErrorBoundary로 에러 핸들링하기 (3) | 2023.12.10 |
[React-Query] React Query로 페이지네이션하기(keepPreviousData 옵션) (0) | 2023.07.04 |
[React-Query] 쿼리 키 정복하기 (3) | 2023.07.03 |