Master React Context for sharing state across the component tree — and learn the re-render trap that catches every developer.
Imagine a radio broadcast. The radio station broadcasts on a specific frequency. Any radio within range that is tuned to that frequency receives the signal. The station doesn't need to know who is listening, and the radios don't need to be directly connected to the station. React Context works the same way. A Provider broadcasts a value on a specific "channel" (the Context object). Any component in the tree that tunes into that channel with useContext receives the current value. No matter how deeply nested the component is, it can read the context directly — no intermediate components need to pass the value down. The crucial thing to understand about Context is what it is not. Context is not state management — it doesn't manage when and how state changes. That's the job of useState or useReducer. Context is a transmission mechanism: it takes whatever value you give it and makes that value available anywhere below the Provider in the tree. There is a trap that catches almost every developer: when the context value changes, every component that calls useContext re-renders — even if the specific part of the value that component uses didn't change. This is the most important performance characteristic of Context, and it determines every architectural decision around it.
import { createContext, useContext, useState } from 'react'
// Step 1: Create the context
// The argument is the DEFAULT value — used when no Provider exists above
const ThemeContext = createContext('light')
// Step 2: Provide a value to the subtree
function App() {
const [theme, setTheme] = useState('light')
return (
// Any component inside this Provider can read theme/setTheme
<ThemeContext.Provider value={{ theme, setTheme }}>
<Navbar />
<Main />
<Footer />
</ThemeContext.Provider>
)
}
// Step 3: Consume anywhere in the subtree — no prop drilling
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext)
return (
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
)
}
// ThemeToggle can be nested 10 levels deep — still reads theme directly
const UserContext = createContext(null)
// null is the default — used when component renders OUTSIDE any Provider
function ProfileAvatar() {
const user = useContext(UserContext)
// If rendered without UserContext.Provider above it, user is null (the default)
return user ? <img src={user.avatar} /> : <GuestIcon />
}
// Inside app — Provider exists
<UserContext.Provider value={currentUser}>
<ProfileAvatar /> {/* user = currentUser */}
</UserContext.Provider>
// In Storybook / unit test — no Provider
<ProfileAvatar /> {/* user = null (default) — no crash */}
This is the most important thing to understand about Context performance:
const AppContext = createContext(null)
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
const [notifications, setN] = useState([])
// ✗ One context for everything
return (
<AppContext.Provider value={{ user, theme, notifications, setUser, setTheme, setN }}>
<Navbar /> {/* re-renders when ANYTHING in value changes */}
<Main /> {/* re-renders when ANYTHING in value changes */}
</AppContext.Provider>
)
}
// Navbar only uses theme — but re-renders when user changes or notifications change!
function Navbar() {
const { theme } = useContext(AppContext) // reads one field
// Re-renders when user changes or notifications change — wasteful!
return <nav className={theme}>...</nav>
}
// Separate contexts — each only triggers its own consumers
const UserContext = createContext(null) // changes on login/logout
const ThemeContext = createContext('light') // changes on toggle
const NotificationContext = createContext([]) // changes frequently
function App() {
const [user, setUser] = useState(null)
const [theme, setTheme] = useState('light')
const [notifications, setN] = useState([])
return (
<UserContext.Provider value={{ user, setUser }}>
<ThemeContext.Provider value={{ theme, setTheme }}>
<NotificationContext.Provider value={{ notifications, setN }}>
<AppContent />
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
)
}
// Navbar only re-renders when theme changes — not when user or notifications change
function Navbar() {
const { theme } = useContext(ThemeContext) // isolated
return <nav className={theme}>...</nav>
}
Since useReducer's dispatch function is always stable, putting it in a separate context means "dispatch-only" components never re-render when state changes:
const StateContext = createContext(null) // changes on every action
const DispatchContext = createContext(null) // NEVER changes — dispatch is stable
function AppProvider({ children }) {
const [state, dispatch] = useReducer(appReducer, initialState)
return (
<DispatchContext.Provider value={dispatch}> {/* stable forever */}
<StateContext.Provider value={state}> {/* changes on updates */}
{children}
</StateContext.Provider>
</DispatchContext.Provider>
)
}
// Button that only dispatches — NEVER re-renders due to state changes
function AddButton() {
const dispatch = useContext(DispatchContext) // stable reference
return <button onClick={() => dispatch({ type: 'ADD' })}>Add</button>
}
// Display that reads state — re-renders when state changes (expected)
function Counter() {
const { count } = useContext(StateContext)
return <p>Count: {count}</p>
}
// Expose context through a custom hook with runtime error checking
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const login = useCallback(async (creds) => { /* ... */ }, [])
const logout = useCallback(async () => { setUser(null) }, [])
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be inside AuthProvider')
return ctx
}
// Usage — clear error if Provider is missing instead of silent null crash
function ProfilePage() {
const { user } = useAuth() // throws immediately if no AuthProvider above
return <div>{user.name}</div>
}
| Approach | Best for | Avoid when |
|---|---|---|
| Props | 1-2 levels, explicit data flow | 3+ levels, many consumers |
| Composition (children) | Avoiding drilling without context | Can't restructure hierarchy |
| Context | Theme, auth, locale — infrequently changing | High-frequency updates |
| Zustand / Redux | Complex state, frequent updates, DevTools | Simple local state |
Many developers think Context is state management — but Context is a transmission mechanism, not state management. It broadcasts a value; it doesn't decide when that value changes. useState and useReducer manage the state. Context distributes it. Conflating the two leads to architectural mistakes like putting all application state in a single context object.
Many developers think components only re-render when the part of context they use changes — but any component calling useContext re-renders whenever the Provider's value prop reference changes, regardless of which fields the component reads. A component reading only user.name still re-renders when theme changes, if they're in the same context.
Many developers think the default value in createContext is a good fallback for missing Providers — using null as a default and checking in a custom hook is better because it throws an explicit error when a component is used outside its Provider, rather than silently using a potentially incorrect default value.
Many developers think nested Providers always create isolated scopes — Providers of the same context CAN be nested, and each acts as a new source of that context for its subtree. The nearest Provider above a consumer wins. This can be used intentionally for theme overrides or test mocking, but accidental nesting can cause confusion.
Many developers think Context is the right solution for all global state — Context re-renders all consumers on every value change. For state that updates frequently (every keystroke, every WebSocket message, every animation frame), use Zustand or Jotai which have built-in subscription optimization that Context lacks.
Authentication state is the canonical Context use case. Auth context provides user, login, logout, and loading — used across the entire app tree in navigation, protected routes, and profile sections. It changes rarely (login/logout events) making it ideal for Context without performance concerns.
Theme systems at companies like CRED and PhonePe use multiple nested Theme Contexts: a global theme at the app root and local theme overrides at the section level. A dark-mode card inside a light-mode page is achieved by wrapping the card in a ThemeContext.Provider with the dark values — the nearest provider wins.
Internationalization (i18n) libraries like react-i18next use Context to provide the translation function (t) to all components. Since the locale rarely changes, Context re-renders are acceptable. Components call useContext to get t() and render translated strings without knowing which language is active.
Toast notification systems typically use Context to provide an addToast function to all components. A component deep in the tree can trigger a toast without prop drilling by calling const { toast } = useToast() — the toast manager is at the root, dispatching creates the notification, and the Context provides the dispatch function everywhere.
Multi-step forms use a FormContext to share form state and dispatch across step components without lifting all state to a parent that doesn't otherwise need it. Each step reads and updates the relevant slice of form state through the context.
No questions tagged to this topic yet.
Reading answers is not the same as knowing them. Practice saying them out loud with AI feedback — that's what builds real interview confidence.