React로 개발을 하면 갖게되는 많은 궁금증 중 하나일 것이다.
context API 를 이용해서 상태 값들을 관리할 수 있는데 Redux를 쓰는 이유가 뭐지?
여러 매체를 통해서 배워보면
Context API는 상태관리 툴이 아닌 종속성 주입을 위한 도구이고, 주로 prop drilling을 해결하기 위해 사용된다는 것임을 알 수 있다.
그러니 진짜로 "전역 상태관리를 위한 툴" 로서 무언가를 사용하고 싶다면
를 사용하는 것이 옳다고 보여진다.
와 useState, useReducer와 같은 상태관리 훅을 함께 사용한다면 Redux와 비슷한 효과를 볼 수 있기는 하다.
하지만 어째든 근본적으로 이 둘을 비교하는 것은 개념상 맞지는 않아보인다.
그래도 많은 부분에서 어떤 툴을 사용할 지 혼동이 되는 부분이 있을 수 있으니
위 두개 중 하나를 선택해서 만들고자 한다면 다음의 팁이 제안이 될 수 있을 것 같다.
contextapi | redux |
---|---|
자주 바뀌지 않는 값을 전달해야하는 경우 | 앱의 여러 곳에서 많은 양의 상태 값이 사용되는 경우 |
단순히 prop-drilling 문제를 해결하고 싶은 경우 | 앱에서 사용되는 상태 값이 자주 변경되는 경우 |
소 ~ 중 규모의 프로젝트로 굳이 외부 의존성을 늘리고 싶지 않은 경우 | 관리해야하는 상태 값의 업데이트 로직이 복잡한 경우 |
큰 규모의 프로젝트에서 전역 상태관리가 필요한 경우 |
이 정도가 기본적으로 context api 와 redux를 비교하는 내용으로 알고 있는 전부다.
그런데 렌더링 측면에서도 큰 차이점이 있다는 것을 알게 되었다.
Context Api의 Provider 로 감싸진 자식 컴포넌트들은 Provider 에서 값의 변경이 생기면 전부 리렌더링 된다는 것
기본적으로 리액트의 모든 컴포넌트는 부모 컴포넌트에서 온 props에 변경이 생기지 않아도 부모 컴포넌트가 리렌더링되면 전부 리렌더링을 거치게 된다.
이는 자연스러운 리액트의 동작이지만 불필요하게 많은 리렌더링을 만들 이유는 없다.
를 사용해서 부모에서 사용한 상태 값을 모든 자식이 아닌 특정한 자식 컴포넌트에만 물려준다 하더라도
모든 자식 컴포넌트는 전부 리렌더링 된다는 것이다.
이는 Redux와의 큰 차이이기도 하다.
진짜인지 확인을 위해 간단한 counter 코드를 구현했다.
export const CountContext = createContext({
incrementCount: () => {},
decrementCount: () => {},
count: 0
});
function App() {
const [count, setCount] = useState(0);
const incrementCount = () => setCount((prev) => prev + 1);
const decrementCount = () => setCount((prev) => prev - 1);
const contextValue = {
count,
incrementCount,
decrementCount
};
return (
<CountContext.Provider value={contextValue}>
{...}
</CountContext.Provider>
);
};
export default App;
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
count: 0
};
const countSlice = createSlice({
name: "count",
initialState,
reducers: {
increment: (state) => {
state.count += 1;
},
decrement: (state) => {
state.count -= 1;
}
}
});
export const { increment, decrement } = countSlice.actions;
export default countSlice.reducer;
context api를 사용한 경우는 특정 컴포넌트 구간에만 적용한다는 가정이지만 여기서는 App.js 사용했다.
Redux의 경우 index.js에 적용을 했다.
const rootElement = document.getElementById("root");
const root = createRoot(rootElement);
root.render(
<StrictMode>
<Provider store={store}> -> redux
<App />
</Provider>
</StrictMode>
);
index.js 의 모습이다. redux provide 를 여기서 App 컴포넌트로 전달해 모든 컴포넌트에서 사용할 수 있도록 했다.
// context api 를 사용한 App.js 모습
function App() {
const [count, setCount] = useState(0);
const incrementCount = () => setCount((prev) => prev + 1);
const decrementCount = () => setCount((prev) => prev - 1);
const contextValue = {
count,
incrementCount,
decrementCount
};
return (
<CountContext.Provider value={contextValue}>
<div className="context">
<button onClick={incrementCount}>App context button</button>
<ContextParentComponent />
</div>
</CountContext.Provider>
);
}
export default App;
// redux 를 사용한 모습
function App() {
const dispatch = useDispatch();
return (
<div className="App">
<div className="redux">
<button onClick={() => dispatch(increment())}>App button</button>
<ReduxParentComponent />
</div>
</div>
);
}
export default App;
App.js 에서 두가지 경우를 나눠서 결과를 보도록 하겠다.
그리고 App.js 에서는 count를 올려주는 기능을 하는 버튼 하나만 두었다.
는 이름은 다르지만 똑같은 모습을 하고 있다.
import React from "react";
import ChildComponent from "./contextChildComponent";
const ContextParentComponent = () => {
console.log("re-render parent");
return (
<>
<h1>CONTEXT</h1> // redux에서는 <h1>REDUX</h1>
<ChildComponent />
</>
);
};
export default ContextParentComponent;
여기서는 단순히 re-render parent
라는 console.log 만 찍는다
// context api 를 사용한 모습
const ChildComponent = () => {
const { count, incrementCount, decrementCount } = useContext(CountContext);
return (
<>
<p>{count}</p>
<button onClick={incrementCount}>add </button>
<button onClick={decrementCount}>subtract </button>
</>
);
};
export default ChildComponent;
// redux 를 사용한 모습
const ReduxChildComponent = () => {
const dispatch = useDispatch();
const { count } = useSelector((state) => state.count);
return (
<>
<p>{count}</p>
<button onClick={()=> dispatch(increment())}>add </button>
<button onClick={()=> dispatch(decrement())}>subtract </button>
</>
);
};
export default ReduxChildComponent;
이러한 코드로 각각 구현된 상태에서 과연 부모의 increment 버튼을 클릭하면
ParentComponent
의 console.log 가 찍힐까?
밑의 Console 부분을 보면 숫자가 증가하는 것을 볼 수 있다.
즉 중간에서 아무 역할을 하지않고 값을 전달 받지도 않지만 리렌더링이 일어나고 있다.
App context button을 클릭했지만 밑의 add 와 subtract를 클릭해도 동일하다.
보시다시피 전혀 리렌더링이 일어나지 않는 것을 볼 수 있다.
App button을 클릭했지만 밑의 add 와 subtract를 클릭해도 동일하다.
context api에서 사용된 값은 increment나 decrement를 수행하면 해당 context provider 컴포넌트의 상태가 업데이트 되어 업데이트된 새로운 value를 전파하기 때문에 자식컴포넌트를 돌며 리렌더링을 일으키는 것이다.
redux에서도 내부적으로 context api를 사용하지만 이는 새로운 상태 value를 전달하는 것이 아니라 Redux 저장소 인스턴스를 전달하기 때문에 redux는 provider에 항상 동일한 상태를 전달하게 된다.
Redux를 사용하더라도 상위 컴포넌트에서 useSelector를 사용하여 구독을 하고 있으면 리렌더링이 일어나기는 한다.
이런 경우나 위 Context API를 사용하는 경우 하위 컴포넌트로 불필요한 리렌더링이 전파되는 것을 막기 위해서는
provider가 안의 컴포넌트 중 상단에서 React.memo를 사용하면 된다.
예)
import React from "react";
import ChildComponent from "./contextChildComponent";
const ContextParentComponent = () => {
console.log("re-render parent");
return (
<>
<h1>CONTEXT</h1> // redux에서는 <h1>REDUX</h1>
<ChildComponent />
</>
);
};
export default React.memo(ContextParentComponent);
이렇게 따로 props를 받거나 하지 않는 곳에서 사용하면 되는데
일일히 전부 사용하는 것이 아니라 context api 안 바로 최상단의 컴포넌트에서 사용하면 된다.
아니면 특정 컴포넌트 부분에 context를 바로 만들지 않고 외부 js파일에 context 을 만들어 Props.children으로 감싸서 내보내도 된다.
import { createContext, useEffect, useState } from "react";
export const CountContext = createContext({
incrementCount: () => {},
decrementCount: () => {},
count: 0
});
const CountProvider = ({children}) => {
const [count, setCount] = useState(0);
const incrementCount = () => setCount((prev) => prev + 1);
const decrementCount = () => setCount((prev) => prev - 1);
const contextValue = {
count,
incrementCount,
decrementCount
};
return (
<CountContext.Provider value={contextValue}>
{children}
</CountContext.Provider>
);
};
export default CountProvider;
props.children은 자식 컴포넌트를 그래도 전달하기에 re-render 가 발생하지 않는다.
렌더링 관점에서 위 둘을 비교한 사례는 잘 보지 못했는데 최근 여러 유명하신 분들의 글을 보게 되며 렌더링 관점에서 나름 정리를 하고 이해를 하는 시간을 가지게 된 것 같다.
참고
https://kofearticle.substack.com/p/korean-fe-article-react