The problem Context solves
Prop drilling — passing data through intermediate components that don't need it — becomes painful once your tree grows deep. The Context API is React's built-in answer: share a value across a subtree without threading it through every layer.
// Without Context — theme travels through every layer
<App theme="dark">
<Layout theme="dark">
<Sidebar theme="dark">
<Button theme="dark" /> {/* finally used here */}
</Sidebar>
</Layout>
</App>
// With Context — Button reads theme directly
const ThemeContext = createContext('light')
function App() {
return (
<ThemeContext.Provider value="dark">
<Layout /> {/* no theme prop */}
</ThemeContext.Provider>
)
}
function Button() {
const theme = useContext(ThemeContext)
return <button className={theme}>Click</button>
}
Layout and Sidebar are completely unaware of theme. Context jumps
straight from the Provider to the consumer.
The three building blocks
1. createContext
const ThemeContext = createContext('light')
Creates a Context object. The argument is the default value — used only when a component reads the context without any Provider above it. It's not the initial value of the Provider; it's the fallback for orphaned consumers (useful in tests and Storybook).
2. Context.Provider
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
Wraps a subtree and supplies a value. Every consumer inside the Provider
receives this value. The value prop is compared by reference — a new object
reference triggers a re-render in all consumers.
3. useContext
const theme = useContext(ThemeContext)
Reads the nearest Provider's value. If there's no Provider above, it returns
the createContext default. This hook replaces the older Context.Consumer
render-prop API entirely.
Re-render behaviour — the detail most developers miss
Every component that calls useContext(MyContext) re-renders when the
Provider's value changes. Intermediate components that don't consume the
context are unaffected.
const CountCtx = createContext(0)
function Parent() {
const [count, setCount] = useState(0)
// New object literal every render → all consumers re-render on each click
return (
<CountCtx.Provider value={{ count, setCount }}>
<Middle />
</CountCtx.Provider>
)
}
function Middle() {
// Does NOT call useContext → NOT re-rendered when count changes
return <Consumer />
}
function Consumer() {
const { count } = useContext(CountCtx) // re-renders on every count change
return <span>{count}</span>
}
The critical mistake: creating a new object literal in the Provider on every
render. Fix it with useMemo:
const value = useMemo(() => ({ count, setCount }), [count, setCount])
<CountCtx.Provider value={value}>
If value is a primitive (string, number), memoisation is unnecessary —
React compares primitives by value.
Writing a custom hook to wrap useContext
Exporting the raw Context object leaks implementation details and gives no error guidance. Always wrap in a custom hook:
// auth/AuthContext.jsx
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const logout = useCallback(() => { setUser(null) }, [])
const value = useMemo(() => ({ user, setUser, logout }), [user, logout])
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}
Consumers import useAuth() — no context object, no null checks, a clear
error if used incorrectly:
function ProfileMenu() {
const { user, logout } = useAuth()
return <button onClick={logout}>Sign out {user.name}</button>
}
Splitting contexts for performance
If your context has frequently-changing state and stable callbacks, put them in separate contexts. Components that only dispatch won't re-render when state changes.
const StateCtx = createContext(null)
const DispatchCtx = createContext(null)
function StoreProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<DispatchCtx.Provider value={dispatch}> {/* dispatch is stable */}
<StateCtx.Provider value={state}>
{children}
</StateCtx.Provider>
</DispatchCtx.Provider>
)
}
// Only re-renders when state changes
function Display() { return <span>{useContext(StateCtx).count}</span> }
// Never re-renders due to state changes (dispatch never changes)
function IncrBtn() {
const dispatch = useContext(DispatchCtx)
return <button onClick={() => dispatch({ type: 'inc' })}>+</button>
}
Context combined with useReducer — lightweight global store
useReducer in a Provider gives you a Redux-like dispatch pattern with no
extra dependencies:
function reducer(state, action) {
switch (action.type) {
case 'SET_USER': return { ...state, user: action.payload }
case 'LOGOUT': return { ...state, user: null }
default: return state
}
}
export function AppProvider({ children }) {
const [state, dispatch] = useReducer(reducer, { user: null })
const value = useMemo(() => ({ state, dispatch }), [state])
return <AppCtx.Provider value={value}>{children}</AppCtx.Provider>
}
// Consumers dispatch actions — same mental model as Redux
function LogoutButton() {
const { dispatch } = useContext(AppCtx)
return <button onClick={() => dispatch({ type: 'LOGOUT' })}>Log out</button>
}
This is the sweet spot before you need Redux's devtools or middleware.
Where to place Providers in the tree
Place each Provider as low as possible while still wrapping all its consumers. A Provider at the root causes its entire subtree to potentially re-render on every value change.
// ❌ Modal state at root → App re-renders on every open/close
function App() {
const [open, setOpen] = useState(false)
return <ModalCtx.Provider value={{ open, setOpen }}><Everything /></ModalCtx.Provider>
}
// ✅ Scoped to the checkout flow that actually uses it
function CheckoutPage() {
const [open, setOpen] = useState(false)
return (
<ModalCtx.Provider value={{ open, setOpen }}>
<CheckoutForm /><ConfirmModal />
</ModalCtx.Provider>
)
}
Context vs. external stores
| Concern | Context | Redux / Zustand |
|---|---|---|
| Setup | Zero (built-in) | Install + configure |
| Re-render granularity | All consumers of the context | Per-selector |
| Devtools | None | Time-travel, action log |
| Async middleware | DIY | Built-in |
| Infrequent updates | Excellent | Overkill |
| Many consumers, frequent updates | Gets slow | Handles well |
Start with Context. Add a store when:
- Many components subscribe and the context value changes frequently
- You need action logging or time-travel debugging
- You need async middleware without rolling your own
Testing components that use Context
Always render with the real Provider — never mock useContext directly:
function renderWithAuth(ui, user = null) {
return render(
<AuthProvider initialUser={user}>
{ui}
</AuthProvider>
)
}
test('shows username when logged in', () => {
renderWithAuth(<ProfileMenu />, { name: 'Alice' })
expect(screen.getByText('Sign out Alice')).toBeInTheDocument()
})
Key interview points
createContext's argument is the default — the fallback when no Provider exists, not the Provider's initial state.- A new object reference in
valuecauses all consumers to re-render. Memoize withuseMemo. - Always expose Context through a custom hook (
useFoo) rather than exporting the raw Context object. - Split state and dispatch into separate contexts if consumers need only one.
- Place Providers at the lowest ancestor that covers all consumers.
- Context is not a replacement for external stores — it's for infrequently changing cross-cutting concerns.