React Context와 리렌더링
로그인한 사용자 정보와 장바구니를 앱 어디서나 쓰려고 컨텍스트 하나를 만들었다고 해 보겠습니다.
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
return (
<AppContext value={{ user, setUser, cart, setCart }}>
{children}
</AppContext>
);
}화면 오른쪽 위의 사용자 이름만 이 컨텍스트에서 user 를 읽습니다. 그런데 장바구니에 상품을 담을 때마다 사용자 이름 칸도 다시 렌더링됩니다. memo 로 감싸도 마찬가지입니다.
Context 는 전역 상태 관리 도구라는 생각
props 를 여러 단계로 내려 주는 것이 번거로워지면 Context 를 찾게 됩니다. 한 번 넣어 두면 어디서나 꺼내 쓸 수 있으니, 흔히 「React 에 내장된 전역 상태 저장소」로 이해합니다. 그래서 앱의 state 를 하나의 큰 컨텍스트에 모으게 됩니다.
Context 는 저장소가 아닙니다. 값을 담아 두는 기능은 여전히 useState 가 합니다. Context 가 하는 일은 그 값을 중간 컴포넌트를 건너뛰어 아래로 전달하는 것입니다. 저장소가 아니라 통로라고 보면, 앞의 예에서 왜 사용자 이름 칸까지 렌더링되는지 설명됩니다.
값이 바뀌면 읽는 곳 전체가 다시 렌더링된다
useContext 문서의 주의 사항입니다.
React automatically re-renders all the children that use a particular context starting from the provider that receives a different value. The previous and the next values are compared with the Object.is comparison. Skipping re-renders with memo does not prevent the children receiving fresh context values.
규칙은 세 가지입니다. 컨텍스트 값이 바뀌면 그 컨텍스트를 읽는 모든 컴포넌트가 다시 렌더링됩니다. 바뀌었는지는 Object.is 로 판단합니다. 그리고 memo 로는 막을 수 없습니다. 리렌더링 글에서 memo 가 부모의 props 에만 관여하고 context 는 예외라고 했던 것이 이 경우입니다.
앞의 예를 이 규칙에 대 보면 이유가 둘 겹쳐 있습니다. 하나는 user 와 cart 가 한 컨텍스트 값에 묶여 있다는 점입니다. 사용자 이름 칸은 user 만 쓰지만, React 는 컨텍스트 단위로 바뀜을 판단하므로 cart 가 바뀌어도 그 칸을 다시 렌더링합니다.
다른 하나는 value={{ ... }} 가 AppProvider 가 렌더링될 때마다 새 객체라는 점입니다. Object.is 는 내용이 아니라 같은 객체인지를 보므로, 내용이 같아도 매번 「바뀌었다」로 판단됩니다.
자주 바뀌는 값과 드물게 바뀌는 값을 나눈다
첫 번째 이유는 컨텍스트를 나눠서 풉니다.
const UserContext = createContext(null);
const CartContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
const userValue = useMemo(() => ({ user, setUser }), [user]);
const cartValue = useMemo(() => ({ cart, setCart }), [cart]);
return (
<UserContext value={userValue}>
<CartContext value={cartValue}>{children}</CartContext>
</UserContext>
);
}장바구니가 바뀌면 CartContext 를 읽는 곳만 다시 렌더링됩니다. 사용자 이름 칸은 UserContext 만 읽으므로 영향을 받지 않습니다. useMemo 로 감싼 것은 두 번째 이유 때문입니다. user 가 그대로면 userValue 도 같은 객체로 유지되어 불필요한 바뀜 판단이 사라집니다. useContext 문서도 객체나 함수를 값으로 넘길 때 이렇게 useMemo 와 useCallback 으로 고정하라고 안내합니다.
더 나아가 값과 바꾸는 함수를 다른 컨텍스트로 나누는 방법도 있습니다. reducer 와 context 를 함께 쓰는 문서의 예가 TasksContext 와 TasksDispatchContext 를 따로 둡니다. 「담기」 버튼처럼 장바구니를 바꾸기만 하고 읽지는 않는 컴포넌트는 dispatch 컨텍스트만 읽으므로, 장바구니 내용이 바뀌어도 다시 렌더링되지 않습니다.
Context 를 쓰기 전에 먼저 볼 두 가지
Passing Data Deeply with Context 문서는 Context 를 쓰기 전에 두 가지를 먼저 시도하라고 권합니다.
Before you use context, try passing props or passing JSX as children.
첫째, props 로 내려 주기. 두세 단계를 내려 주는 것은 번거로워 보여도, 어떤 컴포넌트가 어떤 데이터를 쓰는지 코드에 그대로 드러납니다. Context 로 바꾸면 그 흐름이 보이지 않게 됩니다.
둘째, children 으로 넘기기. 중간 컴포넌트가 데이터를 쓰지도 않으면서 아래로 전달만 한다면, 대개 구조를 바꿀 신호입니다.
// 중간의 Layout 이 user 를 받아 전달만 하는 구조 대신
<Layout>
<UserBadge user={user} />
</Layout>Layout 은 children 을 그리기만 하고 user 를 몰라도 됩니다. 데이터를 쓰는 컴포넌트를 만드는 쪽에서 직접 props 를 넘기므로 전달 단계가 사라집니다.
Context 가 어울리는 값
문서가 꼽는 쓰임새는 테마, 현재 로그인한 계정, 라우팅, 그리고 규모가 커진 state 관리입니다. 공통점을 보면 트리의 넓은 범위가 읽고, 상대적으로 드물게 바뀌는 값입니다. 다크 모드는 앱 전체가 읽지만 하루에 몇 번 바뀌지 않습니다.
반대로 입력칸의 값, 마우스 위치, 매초 바뀌는 타이머처럼 자주 바뀌는 값을 넓은 범위의 컨텍스트에 두면, 바뀔 때마다 그 컨텍스트를 읽는 곳 전체가 다시 렌더링됩니다. 이런 값은 쓰는 곳 가까이의 state 로 두는 편이 맞다고 봅니다.
한 가지 함정이 더 있습니다. 문서에 따르면 createContext(defaultValue) 의 기본값은 위쪽에 Provider 가 하나도 없을 때만 쓰입니다. Provider 를 두었는데 값이 undefined 라면 기본값이 아니라 undefined 를 받습니다. 그리고 컨텍스트를 읽는 컴포넌트는 Provider 아래에 있어야 하므로, Provider 를 렌더링하는 같은 컴포넌트 안에서 useContext 를 부르면 그 Provider 의 값을 받지 못합니다.
여기까지가 확실한 부분
컨텍스트 값이 바뀌면 읽는 곳 전체가 다시 렌더링된다는 점, Object.is 비교, memo 로 막을 수 없다는 점, 기본값이 쓰이는 조건, Context 전에 props 와 children 을 먼저 시도하라는 권고, 쓰임새 목록은 react.dev 문서 기준입니다. 컨텍스트를 값과 dispatch 로 나누는 방법은 문서의 reducer 와 context 예시를 따랐습니다. 전역 상태를 위한 외부 라이브러리와의 비교는 이 글에서 다루지 않았습니다.
함께 읽기
- React useMemo와 useCallback은 언제 써야 할까?리뷰에서 이런 코드를 자주 만납니다.
- React Error Boundary와 try/catch의 차이대시보드에 위젯이 여섯 개 있습니다. 그중 매출 차트 위젯이 서버에서 예상과 다른 모양의 데이터를 받아 렌더링 중에 오류를 냅니다.
- React useActionState와 useOptimistic으로 폼 다루기견적 요청 폼을 보내는 코드입니다.
- React 커스텀 훅은 state를 공유할까?장바구니 아이콘과 장바구니 페이지가 모두 담긴 상품 수를 보여 줘야 해서, 로직을 커스텀 훅으로 뺐다고 해 보겠습니다.
- React state 구조 설계 원칙견적함 화면에 품목 목록과 합계가 있습니다.