07 — useContext & Prop Drilling
The Problem: Prop Drilling
prop_drilling.js
// ANTI-PATTERN: passing props through components that don't use them
function App() {
const [user, setUser] = useState(null)
return <Layout user={user} setUser={setUser} /> // Layout doesn't use user
}
function Layout({ user, setUser }) {
return <Sidebar user={user} setUser={setUser} /> // Sidebar doesn't use user
}
function Sidebar({ user, setUser }) {
return <UserMenu user={user} setUser={setUser} /> // UserMenu is the actual consumer
}
// 3 levels of drilling for a value only UserMenu needs.
// Adding a new prop to user → update every intermediate component's interface.
// Refactoring any intermediate component → risk breaking the prop chain.
createContext + useContext
context_basics.js
import { createContext, useContext, useState } from 'react'
// 1. Create a context with a default value (used only when NO provider is found)
const AuthContext = createContext(null) // null default = "no auth context"
// 2. Provide a value at the top of the tree
function App() {
const [user, setUser] = useState(null)
return (
<AuthContext.Provider value={{ user, setUser }}>
<Layout />
</AuthContext.Provider>
)
}
// 3. Consume in any descendant — no drilling through intermediate components
function UserMenu() {
const { user, setUser } = useContext(AuthContext) // directly, no props
if (!user) return <LoginButton onClick={() => setUser(loginUser())} />
return <div>{user.name}</div>
}
// Layout and Sidebar no longer need to know about user at all.
Context Re-render Mechanics
context_renders.js
// CRITICAL: when the Provider's value changes, ALL consumers re-render.
// EVERY component that calls useContext(AuthContext) re-renders, regardless of
// whether it uses the part that changed.
// ANTI-PATTERN: new value object every render → all consumers re-render every time
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('dark') // unrelated state
return (
<AuthContext.Provider value={{ user, setUser }}>
{/* Every time setTheme fires, App re-renders → { user, setUser } is a NEW
object → AuthContext value changes → ALL consumers re-render,
even though user didn't change. */}
<Layout />
</AuthContext.Provider>
)
}
// FIX: memoize the value object
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('dark')
const authValue = useMemo(() => ({ user, setUser }), [user])
// setUser is stable (useState guarantees this), so deps only include user.
// Now the value object only changes when user changes → consumers only
// re-render when auth actually changes, not when theme toggles.
return (
<AuthContext.Provider value={authValue}>
<Layout />
</AuthContext.Provider>
)
}
Context Splitting: Stable vs Dynamic
context_splitting.js
// PRODUCTION PATTERN: split contexts by change frequency
// Stable values (setUser, theme config) in one context.
// Dynamic values (user object, loading state) in another.
const AuthActionsContext = createContext(null) // stable: setters, callbacks
const AuthStateContext = createContext(null) // dynamic: user, loading
function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
// Actions are stable — setUser and setLoading are guaranteed stable by useState
const actions = useMemo(() => ({
login: async (credentials) => {
setLoading(true)
const user = await api.login(credentials)
setUser(user)
setLoading(false)
},
logout: () => setUser(null),
}), []) // no deps → stable forever
// State changes on login/logout
const state = useMemo(() => ({ user, loading }), [user, loading])
return (
<AuthActionsContext.Provider value={actions}>
<AuthStateContext.Provider value={state}>
{children}
</AuthStateContext.Provider>
</AuthActionsContext.Provider>
)
}
// A component that only needs login() doesn't re-render when user changes:
function LoginButton() {
const { login } = useContext(AuthActionsContext) // stable → no re-renders from state
return <button onClick={() => login(creds)}>Log In</button>
}
// A component that shows user info re-renders only when user changes:
function Profile() {
const { user, loading } = useContext(AuthStateContext) // re-renders on user change
if (loading) return <Spinner />
return <div>{user?.name}</div>
}
Custom Hooks for Context Consumption
custom_context_hooks.js
// PRODUCTION PATTERN: wrap useContext in a custom hook for type safety + error checking
function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
// This error fires if someone uses useAuth outside <AuthProvider> —
// catches the mistake at runtime with a clear message instead of a
// confusing null reference error deep in a consumer.
}
return context
}
// Usage:
function UserMenu() {
const { user, logout } = useAuth() // clean, self-documenting, type-safe
// ...
}
// The default value should be undefined (not null) to enable the error check:
const AuthContext = createContext(undefined)
// createContext(null) → context === null → the undefined check won't catch it
// createContext(undefined) → context === undefined → the check fires correctly
💡 Tips & Tricks
tips.js
// [Idiom] Always wrap useContext in a custom hook (useAuth, useTheme, useCart).
// Benefits: type safety, clear error if used outside provider, self-documenting.
// [Performance] Memoize context values with useMemo. A new object literal
// { a, b } every render causes ALL consumers to re-render even if a and b
// haven't changed — Object.is fails on new object references.
// [Idiom] Split contexts by change frequency. Put stable values (setters,
// callbacks) in a separate context from dynamic values (data, loading state).
// Consumers of stable values won't re-render when dynamic values change.
// [Debug] If a consumer isn't getting the provider's value, check:
// 1. Is the consumer INSIDE the provider in the tree?
// 2. Is the default value undefined (to enable the "outside provider" error)?
// 3. Are you nesting providers correctly (outer wraps inner)?
// [Idiom] For context values that are just functions (dispatch, callbacks),
// useCallback each function and wrap in useMemo for the value object.
// useState setters are already stable — no need to useCallback them.
⚠️ Edge Cases & Gotchas
edge_cases.js
// [Gotcha] The default value is used ONLY when no provider is found above the
// consumer. If a provider exists but passes value={undefined}, the consumer
// gets undefined — NOT the default. The default is a fallback for missing
// providers, not for undefined values.
// [Gotcha] useContext inside a component that's ABOVE the provider returns the
// default value (or throws if your custom hook checks for it). The provider
// must wrap the consumer in the component tree.
// [Gotcha] Context value changes cause ALL consumers to re-render — there's no
// way to opt out per-consumer. If only one field changes, every consumer that
// uses the context (even for other fields) re-renders. This is why context
// splitting (stable vs dynamic) matters for performance.
// [Gotcha] Nested providers of the same context: the INNERMOST provider wins.
// <ThemeContext.Provider value="dark">
// <ThemeContext.Provider value="light"> ← consumers here get "light"
// <Component />
// </ThemeContext.Provider>
// </ThemeContext.Provider>
// This can be used intentionally for overrides (e.g., a dark section in a light app).
// [Gotcha] Context does NOT participate in bailout optimizations. Even if a
// consumer's props haven't changed and it's wrapped in React.memo, a context
// value change will still trigger a re-render. React.memo only checks props,
// not context. To prevent this, split the context so the consumer subscribes
// only to the slice it needs.
🧠 Spot the Bug
Every keystroke in a search input causes the entire app to re-render, even though the search state is local to one component:
spot_the_bug.js
function App() {
const [user, setUser] = useState(null)
return (
<AuthContext.Provider value={{ user, setUser }}>
<SearchPage />
</AuthContext.Provider>
)
}
function SearchPage() {
const [query, setQuery] = useState('')
// Every keystroke → setQuery → App re-renders → { user, setUser } is a NEW
// object → AuthContext value changes → ALL AuthContext consumers re-render.
return <input value={query} onChange={e => setQuery(e.target.value)} />
}
Answer
The AuthContext.Provider value is { user, setUser } — a new object literal created on every render of App. When SearchPage's local state (query) changes, it causes App to re-render (because SearchPage is a child of App). On App's re-render, { user, setUser } creates a new object reference → the context value changes → all AuthContext consumers re-render, even though user didn't change.
Fix: memoize the value object so it only changes when user changes:
const authValue = useMemo(() => ({ user, setUser }), [user])
// setUser is stable (useState guarantee), so deps only needs user.
// Now the context value is referentially stable across unrelated re-renders.
Key Takeaways
key_takeaways.js
// 1. Context solves prop drilling — provide at the top, consume anywhere below.
// createContext(default) + Provider value={...} + useContext(context).
// 2. When the Provider value changes, ALL consumers re-render — no opt-out.
// Memoize the value with useMemo to prevent unnecessary re-renders.
// 3. Split contexts by change frequency: stable values (setters, callbacks) in
// one context, dynamic values (data, loading) in another. Consumers of
// stable values don't re-render when dynamic values change.
// 4. Wrap useContext in a custom hook (useAuth) with an "outside provider" error
// check. Use createContext(undefined) so the check can distinguish "no provider"
// from "provider with undefined value."
// 5. React.memo does NOT prevent context-triggered re-renders — memo only checks
// props. To prevent context re-renders, split the context so the consumer
// subscribes only to the slice it needs.