What Is Context?
Context provides a way to pass data through the component tree without manually passing props at every level. It solves the "prop drilling" problem โ when you need to pass data through many intermediate components that don't use it themselves.
Without Context (prop drilling):
App (theme="dark")
-> Layout (theme="dark") // doesn't use theme, just passes it
-> Sidebar (theme="dark") // doesn't use theme, just passes it
-> NavItem (theme="dark") // doesn't use theme, just passes it
-> Button (theme="dark") // finally uses theme!
With Context:
App
-> ThemeProvider (value="dark")
-> Layout // no theme prop needed
-> Sidebar // no theme prop needed
-> NavItem // no theme prop needed
-> Button // reads from context directly
Creating and Providing Context
Step 1: Create the Context
import { createContext } from 'react';
// createContext takes a default value (used when no Provider is found above)
const ThemeContext = createContext('light');
Step 2: Provide the Context
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={theme}>
<Header />
<Main />
<Footer />
</ThemeContext.Provider>
);
}
Step 3: Consume the Context
import { useContext } from 'react';
function Button({ children }) {
const theme = useContext(ThemeContext);
return (
<button className={`btn btn-${theme}`}>
{children}
</button>
);
}
Complete Example: Theme Toggle
import { createContext, useContext, useState } from 'react';
// 1. Create context with default value
const ThemeContext = createContext({
theme: 'light',
toggleTheme: () => {},
});
// 2. Create provider component
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 3. Custom hook for cleaner consumption
function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// 4. Consumer components
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
Current: {theme} (click to toggle)
</button>
);
}
function Card({ title, children }) {
const { theme } = useTheme();
return (
<div className={`card card-${theme}`}>
<h2>{title}</h2>
{children}
</div>
);
}
// 5. App setup
function App() {
return (
<ThemeProvider>
<div>
<ThemeToggle />
<Card title="Hello">
<p>This card respects the theme.</p>
</Card>
</div>
</ThemeProvider>
);
}
The useContext Hook
useContext is the modern way to consume context. It replaces the older Context.Consumer render prop pattern.
// Modern (useContext)
function Component() {
const value = useContext(MyContext);
return <div>{value}</div>;
}
// Legacy (Context.Consumer) โ still works but more verbose
function Component() {
return (
<MyContext.Consumer>
{value => <div>{value}</div>}
</MyContext.Consumer>
);
}
How useContext Finds Its Value
Component tree:
<MyContext.Provider value={A}> // Provider 1
<Component /> // reads A
<MyContext.Provider value={B}> // Provider 2 (nested)
<Component /> // reads B (nearest provider)
<MyContext.Provider value={C}> // Provider 3 (deeply nested)
<Component /> // reads C (nearest provider)
</MyContext.Provider>
</MyContext.Provider>
</MyContext.Provider>
<Component /> // reads DEFAULT value (no provider above)
useContext always reads from the nearest Provider above it in the tree. If no Provider is found, it uses the default value from createContext.
Multiple Contexts
Real applications often need multiple contexts. Each context handles one concern.
// auth.context.js
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = async (email, password) => {
const user = await authApi.login(email, password);
setUser(user);
};
const logout = () => {
authApi.logout();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
// theme.context.js
const ThemeContext = createContext({ theme: 'light', toggleTheme: () => {} });
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
return useContext(ThemeContext);
}
// locale.context.js
const LocaleContext = createContext({ locale: 'en', setLocale: () => {} });
function LocaleProvider({ children }) {
const [locale, setLocale] = useState('en');
return (
<LocaleContext.Provider value={{ locale, setLocale }}>
{children}
</LocaleContext.Provider>
);
}
function useLocale() {
return useContext(LocaleContext);
}
// App.js โ compose providers
function App() {
return (
<AuthProvider>
<ThemeProvider>
<LocaleProvider>
<Router />
</LocaleProvider>
</ThemeProvider>
</AuthProvider>
);
}
Provider Composition Helper
When you have many providers, nesting gets deep. A composition helper cleans it up.
function ComposeProviders({ providers, children }) {
return providers.reduceRight(
(acc, [Provider, props]) => <Provider {...props}>{acc}</Provider>,
children
);
}
// Usage
function App() {
return (
<ComposeProviders
providers={[
[AuthProvider, {}],
[ThemeProvider, {}],
[LocaleProvider, { defaultLocale: 'en' }],
[NotificationProvider, {}],
]}
>
<Router />
</ComposeProviders>
);
}
Context vs Prop Drilling
When Prop Drilling Is Fine
Prop drilling is not inherently bad. For shallow trees (2-3 levels), explicit props are clearer and easier to trace.
// This is FINE โ only 2 levels deep
function Page({ user }) {
return (
<div>
<Header user={user} />
<Content user={user} />
</div>
);
}
function Header({ user }) {
return <nav>Welcome, {user.name}</nav>;
}
When Context Is Better
Context shines when data is needed by many components at different tree depths.
// BAD โ prop drilling through 5 levels
<App user={user}>
<Layout user={user}>
<Sidebar user={user}>
<UserSection user={user}>
<Avatar user={user} /> {/* Finally uses it */}
</UserSection>
</Sidebar>
</Layout>
</App>
// GOOD โ context skips intermediate components
<AuthProvider>
<App>
<Layout>
<Sidebar>
<UserSection>
<Avatar /> {/* const { user } = useAuth() */}
</UserSection>
</Sidebar>
</Layout>
</App>
</AuthProvider>
Comparison
| Prop Drilling | Context | |
|---|---|---|
| Explicitness | Very explicit โ you see every data flow | Implicit โ data comes from "somewhere above" |
| Refactoring | Easy to trace | Harder to trace โ need to find Provider |
| Depth | Fine for 1-3 levels | Better for 4+ levels |
| Number of consumers | Few | Many |
| Performance | No extra overhead | All consumers re-render on value change |
| Testing | Easy โ just pass props | Need to wrap with Provider |
Alternative: Component Composition
Before reaching for context, consider if component composition solves the problem.
// Instead of drilling `user` down 4 levels:
function Page({ user }) {
return (
<Layout
sidebar={<Sidebar avatar={<Avatar user={user} />} />}
content={<Content />}
/>
);
}
// Layout doesn't need to know about user at all
function Layout({ sidebar, content }) {
return (
<div className="layout">
<aside>{sidebar}</aside>
<main>{content}</main>
</div>
);
}
Performance: Context Re-renders
Context has a significant performance characteristic: when the Provider value changes, ALL consumers re-render. This can cause unnecessary re-renders if not handled carefully.
The Problem
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
// BUG: New object on every render -> all consumers re-render on ANY change
return (
<AppContext.Provider
value={{ user, setUser, theme, setTheme, notifications, setNotifications }}
>
{children}
</AppContext.Provider>
);
}
// ThemeToggle re-renders when notifications change (unnecessary!)
function ThemeToggle() {
const { theme, setTheme } = useContext(AppContext);
console.log('ThemeToggle rendered');
return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
Problem: Single context with multiple values
notifications updated
-> Provider value is new object (always)
-> ThemeToggle re-renders (reads theme, not notifications)
-> UserProfile re-renders (reads user, not notifications)
-> NotificationBadge re-renders (reads notifications โ correct)
-> EVERY consumer re-renders, even those that don't use the changed data
Fix 1: Split Into Separate Contexts
// Split by concern โ each context only triggers its own consumers
const AuthContext = createContext(null);
const ThemeContext = createContext(null);
const NotificationContext = createContext(null);
function AppProviders({ children }) {
return (
<AuthProvider>
<ThemeProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</ThemeProvider>
</AuthProvider>
);
}
// Now ThemeToggle only re-renders when theme changes
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
// Unaffected by auth or notification changes
return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>{theme}</button>;
}
Fix 2: Memoize the Provider Value
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Memoize the value object to prevent unnecessary consumer re-renders
const value = useMemo(() => ({
theme,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
}), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Fix 3: Separate State and Dispatch Contexts
const TodoStateContext = createContext(null);
const TodoDispatchContext = createContext(null);
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, { todos: [], filter: 'all' });
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
function useTodoState() {
const context = useContext(TodoStateContext);
if (!context) throw new Error('useTodoState must be used within TodoProvider');
return context;
}
function useTodoDispatch() {
const context = useContext(TodoDispatchContext);
if (!context) throw new Error('useTodoDispatch must be used within TodoProvider');
return context;
}
// AddTodo only needs dispatch โ doesn't re-render when state changes
function AddTodo() {
const dispatch = useTodoDispatch();
const [text, setText] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
dispatch({ type: 'ADD_TODO', payload: text });
setText('');
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} />
<button type="submit">Add</button>
</form>
);
}
// TodoList needs state โ re-renders when todos change
function TodoList() {
const { todos, filter } = useTodoState();
const dispatch = useTodoDispatch();
const filtered = todos.filter(todo => {
if (filter === 'active') return !todo.done;
if (filter === 'completed') return todo.done;
return true;
});
return (
<ul>
{filtered.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.done}
onChange={() => dispatch({ type: 'TOGGLE_TODO', payload: todo.id })}
/>
{todo.text}
</li>
))}
</ul>
);
}
Combining Context + useReducer
Context + useReducer is a powerful pattern for medium-complexity state management. It gives you the centralized dispatch pattern of Redux without the external library.
Full Example: Shopping Cart
import { createContext, useContext, useReducer, useMemo } from 'react';
// Types of actions
const ACTIONS = {
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
UPDATE_QUANTITY: 'UPDATE_QUANTITY',
CLEAR_CART: 'CLEAR_CART',
};
// Reducer
function cartReducer(state, action) {
switch (action.type) {
case ACTIONS.ADD_ITEM: {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: i.quantity + 1 }
: i
),
};
}
return {
...state,
items: [...state.items, { ...action.payload, quantity: 1 }],
};
}
case ACTIONS.REMOVE_ITEM:
return {
...state,
items: state.items.filter(i => i.id !== action.payload),
};
case ACTIONS.UPDATE_QUANTITY:
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: action.payload.quantity }
: i
),
};
case ACTIONS.CLEAR_CART:
return { ...state, items: [] };
default:
return state;
}
}
// Contexts
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);
// Provider
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
// Derived values
const enrichedState = useMemo(() => ({
...state,
totalItems: state.items.reduce((sum, i) => sum + i.quantity, 0),
totalPrice: state.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
}), [state]);
return (
<CartStateContext.Provider value={enrichedState}>
<CartDispatchContext.Provider value={dispatch}>
{children}
</CartDispatchContext.Provider>
</CartStateContext.Provider>
);
}
// Hooks
function useCartState() {
const context = useContext(CartStateContext);
if (!context) throw new Error('useCartState must be used within CartProvider');
return context;
}
function useCartDispatch() {
const context = useContext(CartDispatchContext);
if (!context) throw new Error('useCartDispatch must be used within CartProvider');
return context;
}
// Convenience hook combining both
function useCart() {
return {
...useCartState(),
dispatch: useCartDispatch(),
};
}
// Components
function ProductCard({ product }) {
const dispatch = useCartDispatch();
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button
onClick={() => dispatch({ type: ACTIONS.ADD_ITEM, payload: product })}
>
Add to Cart
</button>
</div>
);
}
function CartSummary() {
const { totalItems, totalPrice } = useCartState();
return (
<div className="cart-summary">
<span>{totalItems} items</span>
<span>${totalPrice.toFixed(2)}</span>
</div>
);
}
function CartItems() {
const { items } = useCartState();
const dispatch = useCartDispatch();
return (
<ul>
{items.map(item => (
<li key={item.id}>
<span>{item.name} x{item.quantity}</span>
<span>${(item.price * item.quantity).toFixed(2)}</span>
<button
onClick={() => dispatch({ type: ACTIONS.REMOVE_ITEM, payload: item.id })}
>
Remove
</button>
</li>
))}
{items.length > 0 && (
<button onClick={() => dispatch({ type: ACTIONS.CLEAR_CART })}>
Clear Cart
</button>
)}
</ul>
);
}
// App
function App() {
return (
<CartProvider>
<header>
<CartSummary />
</header>
<main>
<ProductGrid />
<CartItems />
</main>
</CartProvider>
);
}
When Context Is Enough vs External Library
Context Is Enough When
- State is relatively simple (theme, auth, locale, small cart)
- State changes are infrequent (theme toggle, login/logout)
- Few consumers or performance isn't critical
- You want zero dependencies
- The state is "global config" that rarely changes
Consider External Library (Zustand, Redux, Jotai) When
- State is complex with many actions and derived data
- State changes frequently (real-time data, complex forms)
- You need middleware (logging, persistence, undo/redo)
- You need devtools for debugging state changes
- Many components consume different slices of the same state
- You need optimized re-renders (selectors, atomic updates)
Comparison
| Feature | Context | Zustand | Redux Toolkit |
|---|---|---|---|
| Bundle size | 0 KB (built-in) | ~1 KB | ~11 KB |
| Setup | Moderate (Provider + hooks) | Minimal (create store) | More boilerplate |
| Devtools | No | Yes (plugin) | Yes (official) |
| Middleware | No | Yes | Yes |
| Selectors | No (re-renders all consumers) | Yes (fine-grained) | Yes (reselect) |
| Learning curve | Low | Low | Medium |
| Re-render optimization | Manual (split contexts) | Automatic (selectors) | Automatic (selectors) |
| Best for | Theme, auth, locale | Most apps | Large teams, complex apps |
Example: Same Feature in Context vs Zustand
// Context version (more boilerplate, less optimized)
const CountContext = createContext(null);
function CountProvider({ children }) {
const [count, setCount] = useState(0);
const value = useMemo(() => ({ count, setCount }), [count]);
return (
<CountContext.Provider value={value}>
{children}
</CountContext.Provider>
);
}
function useCount() {
return useContext(CountContext);
}
// Zustand version (minimal, auto-optimized)
const useCountStore = create((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),
}));
// No Provider needed, no context, no wrappers
function Counter() {
const count = useCountStore(state => state.count); // Only re-renders when count changes
const increment = useCountStore(state => state.increment);
return <button onClick={increment}>{count}</button>;
}
Testing Context
// Helper to wrap components with providers during testing
function renderWithProviders(ui, { theme = 'light', user = null } = {}) {
return render(
<ThemeContext.Provider value={{ theme, toggleTheme: jest.fn() }}>
<AuthContext.Provider value={{ user, login: jest.fn(), logout: jest.fn() }}>
{ui}
</AuthContext.Provider>
</ThemeContext.Provider>
);
}
// Test
test('shows user name when logged in', () => {
renderWithProviders(<UserGreeting />, {
user: { name: 'Alice', email: '[email protected]' },
});
expect(screen.getByText('Welcome, Alice')).toBeInTheDocument();
});
test('shows login button when not logged in', () => {
renderWithProviders(<UserGreeting />, { user: null });
expect(screen.getByText('Log In')).toBeInTheDocument();
});
Common Mistakes
1. Unstable Provider Value
// BUG: New object every render -> all consumers re-render
function Provider({ children }) {
const [count, setCount] = useState(0);
return (
<MyContext.Provider value={{ count, setCount }}>
{children}
</MyContext.Provider>
);
}
// FIX: Memoize the value
function Provider({ children }) {
const [count, setCount] = useState(0);
const value = useMemo(() => ({ count, setCount }), [count]);
return (
<MyContext.Provider value={value}>
{children}
</MyContext.Provider>
);
}
2. Missing Provider
// No error, just returns default value (often undefined/null)
function Component() {
const value = useContext(MyContext); // undefined if no Provider above
return <div>{value.name}</div>; // TypeError: Cannot read 'name' of undefined
}
// FIX: Custom hook with guard
function useMyContext() {
const context = useContext(MyContext);
if (context === undefined) {
throw new Error('useMyContext must be used within a MyProvider');
}
return context;
}
3. Putting Too Much in One Context
// BAD: Everything in one context
const AppContext = createContext({
user: null,
theme: 'light',
cart: [],
notifications: [],
locale: 'en',
// ... 10 more fields
});
// Changing ANY field re-renders ALL consumers
// GOOD: Split by domain
const AuthContext = createContext(null); // user, login, logout
const ThemeContext = createContext(null); // theme, toggle
const CartContext = createContext(null); // items, add, remove
const NotificationContext = createContext(null); // messages, dismiss
Key Takeaways
- Context solves prop drilling โ pass data through the tree without intermediate props
- createContext + Provider + useContext โ the three pieces of the Context API
- Custom hooks (
useTheme,useAuth) โ wrap useContext for cleaner consumption and error handling - All consumers re-render when Provider value changes โ this is the main performance concern
- Split contexts by domain โ theme, auth, locale, cart should be separate contexts
- Memoize Provider values โ
useMemoprevents unnecessary consumer re-renders - Separate state and dispatch contexts โ components that only dispatch don't re-render on state changes
- Context + useReducer โ a powerful pattern for medium-complexity state without external libraries
- Component composition is sometimes a better solution than context for avoiding prop drilling
- Use external libraries (Zustand, Redux) when you need selectors, devtools, middleware, or optimized re-renders at scale