Context API 기초
React 19의 Context identity, Provider 범위, fallback, 구독 갱신과 value 안정화 계약을 테마 예제로 익힙니다.
여러 하위 컴포넌트가 같은 값을 읽을 때 모든 중간 컴포넌트에 props를 반복해서 전달하면 변경 경로가 길어질 수 있습니다.
React Context는 부모가 특정 하위 트리에 값을 제공하고, 그 아래 컴포넌트가 깊이와 관계없이 그 값을 읽게 하는 전달 채널입니다.
Context 객체 자체가 상태를 저장하거나 소유하는 것은 아닙니다. 상태와 변경 함수는 여전히 컴포넌트의 useState, useReducer 또는 별도 데이터 계층이 소유하고, Context는 그 값을 필요한 범위에 전달합니다.
Identity → owner → provider → consumer
Context는 상태 저장소가 아니라 하위 트리에 값을 전달하는 채널 identity입니다. 컴포넌트가 상태와 변경 함수를 소유하고, Provider가 정한 범위 안의 소비자가 가장 가까운 값을 읽습니다.
createContext(null)
Context 객체는 제공하고 읽을 값의 종류를 식별합니다. 현재 상태를 직접 보관하거나 변경하지 않습니다.
undefined는 fallback이 아니다
기본값은 일치하는 Provider가 없을 때만 사용됩니다.
value={undefined}를 제공하면 소비자도 undefined를 받습니다.
-
State/action owner
ThemeProvider컴포넌트가theme과toggleTheme을 소유합니다. Context 자체는 소유자가 아닙니다. -
React 19 Provider
<ThemeContext>에value={contextValue}를 전달해 필요한 가장 작은 하위 트리를 감쌉니다.ThemeContext.Provider는 이전 문법입니다. -
Pass-through
중간 컴포넌트는 props를 중계하거나 Context를 직접 읽지 않아도 됩니다. 직접 구독하지 않는다는 뜻이지 부모 렌더와 무관하다는 보장은 아닙니다.
-
Consumer
useContext(ThemeContext)를 호출한 컴포넌트가 현재 값을 구독합니다. Provider는 소비자보다 위에 있어야 합니다. -
Nearest provider
Provider가 중첩되면 가장 가까운 값이 이깁니다. Provider가 전혀 없을 때만 정적인 기본값을 읽으며,
nullsentinel은 custom hook에서 누락을 드러낼 수 있습니다.
같은 하위 트리가 읽는 UI 계약
테마, 현재 사용자, 언어처럼 여러 먼 소비자가 같은 값을 읽을 때 범위를 명시합니다.
Local · URL · server/cache
입력 필드는 가까운 상태에, 경로·검색 조건은 router에, 서버 응답과 캐시 수명은 데이터 계층에 둡니다.
Context는 props 전달을 생략할 수 있지만 소유권을 대신하지 않습니다. 범위와 fallback은 API 계약이고, 최적화는 실제 구독과 렌더를 측정한 뒤 적용합니다.
Context가 잘 맞는 경우
Context는 다음처럼 하나의 하위 트리에서 여러 컴포넌트가 읽는 값에 잘 맞습니다.
- 테마와 색상 모드
- 현재 사용자와 권한 UI
- 언어와 지역 설정
- 특정 기능 영역의 상태와 변경 명령
앱의 규모만으로 Context 사용 여부를 정하지는 않습니다. 값의 소유자, 필요한 소비자, Provider 범위, 변경 빈도를 함께 보고 가장 작은 충분한 범위를 선택합니다.
Context API의 세 역할
1. createContext: 채널 identity와 fallback 정의
createContext(defaultValue)는 컴포넌트가 제공하고 읽을 Context identity를 만듭니다. 이 객체 자체에는 현재 상태가 들어 있지 않습니다.
import { createContext, useContext } from 'react';
const ThemeContext = createContext(null);
export function useTheme() {
const value = useContext(ThemeContext);
if (value === null) {
throw new Error('useTheme must be used within ThemeProvider');
}
return value;
}
export default ThemeContext;기본값은 위쪽 트리에 일치하는 Provider가 전혀 없을 때만 쓰는 정적인 마지막 fallback입니다. Provider가 value={undefined}를 제공하면 기본값으로 돌아가지 않고 undefined가 전달됩니다.
이 예제는 Provider 누락을 조용히 숨기지 않도록 null을 sentinel로 두고 useTheme()에서 오류를 냅니다. 반대로 Provider 없이도 의미 있게 동작해야 하는 Context라면 'light'처럼 실제 fallback 값을 사용할 수 있습니다.
2. Provider: 상태 소유자가 현재 값을 제공
설치된 React 19에서는 Context 객체 자체를 Provider로 렌더링합니다. 이전 버전의 <ThemeContext.Provider> 문법도 동작하지만 React 19 공식 문서에서는 legacy 방식입니다.
import { useCallback, useMemo, useState } from 'react';
import ThemeContext from './ThemeContext';
export default function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = useCallback(() => {
setTheme(current => (current === 'light' ? 'dark' : 'light'));
}, []);
const value = useMemo(
() => ({ theme, toggleTheme }),
[theme, toggleTheme],
);
return (
<ThemeContext value={value}>
{children}
</ThemeContext>
);
}ThemeProvider 컴포넌트가 theme 상태와 toggleTheme 동작을 소유합니다. ThemeContext는 이 값을 전달할 채널 identity입니다.
useCallback과 useMemo는 정확성을 위한 필수 요소가 아니라 성능 최적화입니다. 여기서는 Provider가 다른 이유로 다시 렌더될 때 같은 theme에 대해 새 함수와 새 객체를 만들지 않게 합니다. theme가 실제로 바뀌면 value도 바뀌어야 합니다.
3. useContext: 가장 가까운 Provider의 값 구독
import { useTheme } from '../contexts/ThemeContext';
export default function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header className={`header header--${theme}`}>
<p>현재 테마: {theme}</p>
<button type="button" onClick={toggleTheme}>
테마 전환
</button>
</header>
);
}useContext(ThemeContext)는 호출한 컴포넌트 위쪽에서 가장 가까운 ThemeContext Provider를 찾습니다. 같은 컴포넌트가 반환하는 Provider는 그 컴포넌트 안의 useContext 호출에 적용되지 않으므로 Provider는 소비자보다 위에 있어야 합니다.
ThemeContext.Consumer도 남아 있지만 새 함수 컴포넌트에서는 useContext가 권장됩니다.
props 없이 통과하는 중간 컴포넌트
Context를 직접 읽지 않는 컴포넌트는 Context 갱신의 직접 구독자가 아닙니다.
import ThemeProvider from './contexts/ThemeProvider';
import Header from './components/Header';
import MainContent from './components/MainContent';
export default function App() {
return (
<ThemeProvider>
<Header />
<MainContent />
</ThemeProvider>
);
}import ContentSection from './ContentSection';
export default function MainContent() {
return (
<main>
<ContentSection />
</main>
);
}import { useTheme } from '../contexts/ThemeContext';
export default function ContentSection() {
const { theme } = useTheme();
return <section className={`content content--${theme}`}>본문</section>;
}MainContent는 ThemeContext를 직접 읽지 않지만 ContentSection은 읽습니다. 따라서 MainContent가 Context 전파를 위해 props를 중계할 필요는 없습니다.
다만 “Context를 읽지 않는다”가 “절대 다시 렌더되지 않는다”는 뜻은 아닙니다. 부모가 다시 렌더되면 일반적인 부모-자식 렌더 경로로 실행될 수 있고, element identity나 memoization에 따른 bailout이 일부 작업을 건너뛸 수도 있습니다. 직접 Context 구독과 부모 렌더 경로를 구분해서 관찰해야 합니다.
가장 가까운 Provider와 override
Provider를 중첩하면 소비자는 위쪽에서 가장 가까운 Provider의 값을 받습니다.
<ThemeContext value={darkTheme}>
<Page />
<ThemeContext value={lightTheme}>
<Footer />
</ThemeContext>
</ThemeContext>Page 아래 소비자는 darkTheme을, 안쪽 Provider 아래 Footer 소비자는 lightTheme을 읽습니다. Provider 범위는 가능한 한 값을 실제로 필요로 하는 가장 작은 하위 트리에 둡니다.
Context 갱신과 렌더 계약
React는 Provider의 이전 value와 다음 value를 Object.is로 비교합니다.
- 비교 결과가 다르면 그 Context를 읽는 소비자에게 새 값을 전달하고 다시 렌더링합니다.
- 객체와 함수는 내용이 같아 보여도 새 identity이면 다른 값입니다.
memo는 props에 대한 부모 렌더 최적화이므로, 컴포넌트가 직접 읽는 Context의 갱신을 막지 않습니다.- Context를 읽는 바깥 컴포넌트와 필요한 값만 props로 받는 memoized 자식을 분리하는 방식은 별도의 최적화 경계가 될 수 있습니다.
Scope → identity → subscription
Context 최적화는 memo부터 추가하는 일이 아니라 범위와 소유권을 확인하는 일에서 시작합니다. 그다음 value가 왜 달라지는지와 누가 직접 구독하는지를 순서대로 추적합니다.
| 순서 | 확인할 질문 | 유지할 계약 |
|---|---|---|
| 1 · Scope | 어느 하위 트리가 값을 읽는가? | Provider를 모든 페이지의 최상단이 아니라 소비자를 포함하는 가장 작은 충분한 범위에 둡니다. |
| 2 · Owner | 누가 상태와 변경 규칙을 소유하는가? | 컴포넌트의 useState나 useReducer가 소유합니다. Context는 현재 값과 변경 명령을 전달합니다. |
| 3 · Compare | 이전 값과 다음 값이 같은가? | React는 Provider의 value를 Object.is로 비교합니다. |
| 4 · Identity | 객체나 함수가 이유 없이 새로 만들어지는가? | 측정된 비용이 있으면 useCallback과 useMemo로 불필요한 identity 변경을 줄입니다. |
| 5 · 구독자 | 누가 이 Context를 직접 읽는가? | 값이 달라지면 useContext 소비자가 새 값을 받고 다시 렌더됩니다. pass-through는 직접 구독자가 아닙니다. |
| 6 · Memo | memo가 Context 갱신을 막는가? |
막지 못합니다. 바깥에서 Context를 읽고 필요한 값을 memoized 자식의 props로 내리는 별도 경계는 가능합니다. |
| 7 · Split | 소비자 집합과 변경 주기가 다른가? | 관련 없는 값, 또는 state와 안정적인 dispatch를 별도 Context로 나눌 수 있습니다. |
Scope
값을 읽는 소비자를 포함하는 가장 작은 Provider 범위를 고릅니다.
Owner
상태와 변경 규칙은 컴포넌트가 소유하고 Context는 전달만 합니다.
Object.is이전
value와 다음value가 다른지 확인합니다.Value identity
객체·함수가 불필요하게 새로 만들어질 때만
useCallback과useMemo를 검토합니다.Consumer update
직접 Context를 읽는 소비자가 새 값을 받습니다. pass-through는 직접 구독하지 않습니다.
Memo limit
memo는 직접 Context 갱신을 막지 않습니다. props 경계를 따로 만들 때만 역할이 달라집니다.Context split
소비자 집합과 변경 주기가 다르면 Context를 나눕니다.
직접 구독과 부모 렌더는 다른 경로다
Context를 읽지 않는 중간 컴포넌트도 부모 렌더 때문에 실행될 수 있고, element identity나 memoization에 따른 bailout이 일부 작업을 건너뛸 수 있습니다.
Profiler로 원인을 확인한다
Provider 범위, value identity, 직접 소비자, 부모 렌더를 구분해 기록한 뒤 가장 작은 변경을 적용합니다.
value identity 안정화
Provider가 매번 value={{ theme, toggleTheme }}처럼 새 객체와 새 함수를 만들면, 상태가 그대로여도 Provider의 다른 렌더 때문에 소비자가 갱신될 수 있습니다.
측정 결과 이 비용이 의미 있을 때 useCallback으로 함수를, useMemo로 객체 identity를 안정화합니다. 메모이제이션을 무조건 추가하지 말고 React Profiler로 어떤 Provider 변경이 어떤 소비자 렌더를 만들었는지 먼저 확인합니다.
Context 분리
관련 없는 값을 한 객체에 넣으면 한 필드만 바뀌어도 그 Context를 읽는 모든 소비자가 새 값을 받습니다.
다음 기준이 다르면 Context를 나눌 수 있습니다.
- 값을 읽는 소비자 집합
- 변경 빈도와 변경 이유
- 상태를 읽는 컴포넌트와 안정적인 변경 명령만 필요한 컴포넌트
- Provider가 필요한 하위 트리 범위
state와 dispatch를 별도 Context로 나누는 것도 한 예입니다. 다만 작은 테마 객체처럼 비용이 문제가 되지 않는 경우에는 단순한 하나의 Context가 더 읽기 쉽습니다.
Context에 넣지 않을 상태
Context는 모든 상태의 기본 목적지가 아닙니다.
- 한 입력 필드나 작은 컴포넌트만 쓰는 값은 가까운 local state에 둡니다.
- URL 경로와 검색 조건은 router가 소유합니다.
- 서버 응답, 로딩, 재검증, 캐시 수명은 server/cache 데이터 계층에 둡니다.
- Context는 이 원본들을 UI store로 복제하는 수단이 아닙니다.
정리
Context 설계의 핵심은 “얼마나 전역적인가”보다 다음 질문에 답하는 것입니다.
- 누가 상태와 변경 규칙을 소유하는가?
- 어느 하위 트리의 소비자가 값을 읽는가?
- Provider
value는 어떤 이유로 identity가 바뀌는가? - 직접 Context 구독과 부모 렌더 경로가 어떻게 다른가?
- 변경 주기와 소비자 집합이 다른 값은 분리해야 하는가?
다음 절에서는 useReducer가 복잡한 업데이트 규칙을 한 소유자에 모으고, Context가 state와 dispatch를 하위 트리에 전달하는 구조를 살펴봅니다.