App 컴포넌트가 사용자 정보(user)를 가지고 있고, 화면 깊숙한 UserMenu 컴포넌트가 그걸 필요로 한다. 사이에 Layout·Header·Nav 가 있는데, 셋 다 user 를 안 쓰지만 단지 통과시키기 위해 prop 으로 받아 다시 내려보낸다. 이게 prop drilling — 그리고 React Context 가 푸는 문제.
이번 13편은 Context 의 3 부품(createContext·Provider·useContext) + 언제 써야 하고 언제 쓰면 안 되는지 + 성능 함정 + 회사 stack 에 두기 좋은 대안 (Zustand) 까지.
1. 3 부품 — createContext · Provider · useContext
// 1. Context 생성
import { createContext, useContext } from 'react';
const ThemeContext = createContext<'light' | 'dark'>('light');
// 2. Provider — 값 공급
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Layout />
</ThemeContext.Provider>
);
}
// 3. useContext — 어느 깊이에서든 읽기
function DeepButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>...</button>;
}
Layout·Header·중간 컴포넌트들이 theme 을 prop 으로 받지 않는다. DeepButton 이 useContext 한 줄로 직접 접근. prop drilling 5단계가 0단계.
핵심 이해 — Context 가 마법은 아니다. React 가 컴포넌트 트리를 올라가며 가장 가까운 Provider 를 찾아 그 value 를 반환할 뿐. Provider 가 없으면 createContext 의 기본값 사용. Provider 가 여러 개면 가장 가까운 것 우선.
2. 진짜 패턴 — value + updater 같이 공급
위 예제는 theme 만 공급. 실전에선 값 + 변경 함수를 객체로 묶어 공급:
type ThemeContextType = {
theme: 'light' | 'dark';
toggle: () => void;
};
const ThemeContext = createContext<ThemeContextType | null>(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const toggle = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggle }}>
{children}
</ThemeContext.Provider>
);
}
// 편의 Hook
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be inside ThemeProvider');
return ctx;
}
// 사용
function ThemeToggle() {
const { theme, toggle } = useTheme();
return <button onClick={toggle}>{theme}</button>;
}
이 패턴 3가지 장점 — ① 같은 컨텍스트에서 값·변경 함수를 한 번에. ② 커스텀 Hook (useTheme) 이 Provider 없이 사용하는 실수를 컴파일/런타임 둘 다에서 잡음. ③ 12편 커스텀 Hook 와 자연스러운 결합.
3. 성능 함정 — Provider value 가 매 렌더마다 새 객체
위 코드의 value={{ theme, toggle }} 가 사고 지점. 부모 컴포넌트가 다른 이유로 재렌더되면 매번 새 객체 가 만들어진다 → Context 구독자 전부 재렌더. theme 이 안 바뀌어도.
해결 — useMemo 로 객체 메모이즈 (16편 자세히):
const value = useMemo(() => ({ theme, toggle }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
또는 — state 와 dispatch 를 다른 Context 두 개로 분리. 값만 필요한 컴포넌트는 state Context, 변경만 필요한 컴포넌트는 dispatch Context 구독. dispatch 는 안 바뀌니 재렌더 0. Redux 가 옛날부터 쓰던 패턴.
4. Context 의 한계 — Zustand 가 답일 때
Context 가 적합한 경우 — 잘 안 바뀌는 전역 값 (theme·locale·로그인 사용자·인증 토큰). 자주 바뀌는 state (장바구니 항목 수·실시간 알림 카운트) 에 Context 를 쓰면 위 성능 함정이 일상.
그런 경우 — Zustand · Jotai · TanStack Store 같은 외부 상태 관리. 회사 stack 에 Zustand 가 자주 들어가는 이유다.
// Zustand 예시 — Context 보다 간단
import { create } from 'zustand';
const useCartStore = create((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}));
function CartCount() {
const count = useCartStore((s) => s.items.length); // ← selector
return <span>{count}</span>;
}
핵심 — Zustand 는 selector 로 "이 컴포넌트는 items.length 만 본다" 명시. items 배열 자체가 바뀌어도 length 가 같으면 재렌더 0. Context 가 못 하는 일.
실전 가이드 — 작은 앱 + 잘 안 바뀌는 값 → Context. 큰 앱 + 자주 바뀌는 값 → Zustand (또는 Redux Toolkit). 둘 다 쓰는 게 정상 — 정답은 둘 중 하나가 아님.
13편으로 컴포넌트 간 데이터 공유 패턴이 완성. 14편 useReducer 는 그 안에서 다루는 state 가 복잡해질 때 useState 의 진화형. Context + useReducer 조합이 Redux 직전까지 커버한다.
다음 글
React 교재 14편 — useReducer. 복잡한 state + action + reducer 패턴, useState 와 언제 갈아탈지 기준.