React Context API: Global State Management
Every React developer encounters the same problem eventually: you have data that multiple components need, but those components are buried deep in the component tree. Passing props down through five or six levels of intermediary components — components that don't even use those props — is painful, error-prone, and makes your code brittle. This is prop drilling, and React Context API was built specifically to solve it.
Introduced in React 16.3 and significantly improved with React Hooks in 16.8, the Context API gives you a way to share values between components without explicitly threading them through every level of the tree. When used correctly, it's a powerful built-in alternative to libraries like Redux for many real-world use cases.
Context is ideal for data that is global within a component tree: the current authenticated user, theme preferences, selected locale/language, or shopping cart contents. If state only affects a few sibling components, lift state up instead. Context is not a silver bullet — overusing it leads to unnecessary re-renders and harder-to-trace data flows.
The Problem: Prop Drilling
Before diving into Context, let's visualize the exact problem it solves. Imagine an app where a user object needs to reach a deeply nested component:
Prop drilling: data flows through components that don't need it
With Context API, the App component places the user value into a context, and UserCard reads it directly — no middlemen required.
Core Concepts: Context, Provider, Consumer
The Context API is built around three primitives:
- createContext: Creates a context object with an optional default value
- Provider: A component that supplies the context value to its subtree
- useContext: A hook that reads the current context value inside a function component
Creating a Context
import { createContext } from 'react';
// The argument to createContext is the DEFAULT value —
// used only when a component has no matching Provider above it.
// null is a common default when a Provider is always required.
const UserContext = createContext(null);
export default UserContext;
Wrapping with a Provider
The Provider component accepts a value prop and makes it available to all descendants. Any component in the tree that reads this context will receive this value:
import { useState, useEffect } from 'react';
import UserContext from './context/UserContext';
import Layout from './components/Layout';
function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchCurrentUser().then(data => {
setUser(data);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
// value prop is what consumers will receive
return (
<UserContext.Provider value={{ user, setUser }}>
<Layout />
</UserContext.Provider>
);
}
export default App;
Consuming Context with useContext
import { useContext } from 'react';
import UserContext from '../context/UserContext';
function UserCard() {
// Reads from the nearest matching Provider above in the tree
const { user } = useContext(UserContext);
if (!user) return null;
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default UserCard;
Notice that Layout and Sidebar no longer need to accept or forward a user prop. They can remain completely unaware of the user data.
Building a Complete Context Pattern
A best practice is to encapsulate context creation, provider logic, and the consumer hook into a single file. This gives you a clean, self-contained module that's easy to import and hard to misuse.
import { createContext, useContext, useState, useEffect } from 'react';
// 1. Create the context (not exported directly)
const AuthContext = createContext(null);
// 2. Build the Provider component with all state logic
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(() => localStorage.getItem('token'));
useEffect(() => {
if (token) {
validateToken(token)
.then(userData => setUser(userData))
.catch(() => {
localStorage.removeItem('token');
setToken(null);
});
}
}, [token]);
const login = async (email, password) => {
const { user: userData, token: newToken } = await apiLogin(email, password);
localStorage.setItem('token', newToken);
setToken(newToken);
setUser(userData);
};
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setUser(null);
};
const value = { user, token, login, logout, isAuthenticated: !!user };
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
// 3. Custom hook — the only way consumers should access this context
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
The throw new Error guard inside useAuth is intentional. If a developer accidentally uses useAuth() outside the AuthProvider, they get an immediate, descriptive error instead of a cryptic null value bug that surfaces later. This pattern catches mistakes at development time.
Using the Auth Context
import { useAuth } from '../context/AuthContext';
import { Link } from 'react-router-dom';
function Navbar() {
const { user, logout, isAuthenticated } = useAuth();
return (
<nav>
<Link to="/">Home</Link>
{isAuthenticated ? (
<>
<span>Welcome, {user.name}!</span>
<button onClick={logout}>Logout</button>
</>
) : (
<Link to="/login">Login</Link>
)}
</nav>
);
}
export default Navbar;
Theme Context: A Practical Example
Theme switching is one of the most common use cases for Context. Here's a complete, production-ready implementation that persists the user's preference:
import { createContext, useContext, useState, useEffect } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
// Read from localStorage on first render, fall back to system preference
const saved = localStorage.getItem('theme');
if (saved) return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
});
useEffect(() => {
// Apply theme to the document root
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () =>
setTheme(prev => (prev === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
};
import { useTheme } from '../context/ThemeContext';
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme} aria-label="Toggle theme">
{theme === 'dark' ? '☀️ Light Mode' : '🌙 Dark Mode'}
</button>
);
}
export default ThemeToggle;
Performance: Avoiding Unnecessary Re-renders
The biggest pitfall with Context API is performance. Every time the context value changes, all components that consume that context will re-render — even if the specific piece of data they use hasn't changed. Here are strategies to keep your app fast:
Strategy 1: Split Contexts by Update Frequency
If you have state that changes frequently alongside state that rarely changes, keep them in separate contexts. Components only re-render when their specific context changes:
// BAD: one context for everything — all consumers re-render on any change
const AppContext = createContext({ user, theme, cart, notifications });
// GOOD: separate contexts — components only subscribe to what they use
const UserContext = createContext(null); // Changes rarely
const ThemeContext = createContext(null); // Changes on user toggle
const CartContext = createContext(null); // Changes frequently
const NotificationsContext = createContext(null); // Changes on events
Strategy 2: Memoize the Context Value
If your Provider's parent component re-renders, the context value object is recreated on every render, triggering unnecessary consumer re-renders. Wrap the value with useMemo to prevent this:
import { createContext, useContext, useState, useMemo } from 'react';
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
const addItem = (product) =>
setItems(prev => [...prev, product]);
const removeItem = (id) =>
setItems(prev => prev.filter(item => item.id !== id));
const total = items.reduce((sum, item) => sum + item.price, 0);
// Without useMemo, { items, addItem, removeItem, total } is a new
// object reference on every render, re-rendering all consumers.
const value = useMemo(
() => ({ items, addItem, removeItem, total }),
[items] // Only recreate when items actually changes
);
return (
<CartContext.Provider value={value}>
{children}
</CartContext.Provider>
);
}
export const useCart = () => useContext(CartContext);
Strategy 3: Separate State and Dispatch
For complex state, use useReducer and put state and dispatch into separate contexts. Components that only dispatch actions (like buttons) won't re-render when state changes:
import { createContext, useContext, useReducer } from 'react';
const CountStateContext = createContext(null);
const CountDispatchContext = createContext(null);
function countReducer(state, action) {
switch (action.type) {
case 'increment': return { count: state.count + 1 };
case 'decrement': return { count: state.count - 1 };
case 'reset': return { count: 0 };
default: throw new Error(`Unknown action: ${action.type}`);
}
}
export function CountProvider({ children }) {
const [state, dispatch] = useReducer(countReducer, { count: 0 });
return (
<CountStateContext.Provider value={state}>
<CountDispatchContext.Provider value={dispatch}>
{children}
</CountDispatchContext.Provider>
</CountStateContext.Provider>
);
}
// Components that display count — re-render when count changes
export const useCountState = () => useContext(CountStateContext);
// Components that only dispatch — never re-render due to count changes
export const useCountDispatch = () => useContext(CountDispatchContext);
// Usage in a display component:
function Counter() {
const { count } = useCountState();
return <h1>Count: {count}</h1>;
}
// Usage in a button component — won't re-render when count changes!
function IncrementButton() {
const dispatch = useCountDispatch();
return <button onClick={() => dispatch({ type: 'increment' })}>+</button>;
}
Real-World: Shopping Cart Context
Let's put everything together with a complete shopping cart implementation that you'd actually ship:
// src/context/CartContext.jsx
import { createContext, useContext, useReducer, useMemo } from 'react';
const CartContext = createContext(null);
const cartReducer = (state, action) => {
switch (action.type) {
case 'ADD_ITEM': {
const exists = state.items.find(i => i.id === action.payload.id);
if (exists) {
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 'REMOVE_ITEM':
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
case 'UPDATE_QUANTITY':
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, quantity: action.payload.quantity }
: i
),
};
case 'CLEAR_CART':
return { ...state, items: [] };
default:
throw new Error(`Unknown action: ${action.type}`);
}
};
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
const value = useMemo(() => ({
items: state.items,
itemCount: state.items.reduce((sum, i) => sum + i.quantity, 0),
total: state.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
addItem: (product) => dispatch({ type: 'ADD_ITEM', payload: product }),
removeItem: (id) => dispatch({ type: 'REMOVE_ITEM', payload: id }),
updateQuantity: (id, quantity) =>
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }),
clearCart: () => dispatch({ type: 'CLEAR_CART' }),
}), [state]);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export const useCart = () => {
const ctx = useContext(CartContext);
if (!ctx) throw new Error('useCart must be used within CartProvider');
return ctx;
};
// src/components/Cart.jsx
import { useCart } from '../context/CartContext';
function Cart() {
const { items, total, itemCount, removeItem, updateQuantity, clearCart } = useCart();
if (items.length === 0) {
return <div className="cart-empty">Your cart is empty</div>;
}
return (
<div className="cart">
<div className="cart-header">
<h2>Cart ({itemCount} items)</h2>
<button onClick={clearCart}>Clear All</button>
</div>
{items.map(item => (
<div key={item.id} className="cart-item">
<img src={item.image} alt={item.name} />
<div>
<h3>{item.name}</h3>
<p>${item.price.toFixed(2)}</p>
</div>
<div className="quantity-control">
<button onClick={() => updateQuantity(item.id, item.quantity - 1)}
disabled={item.quantity === 1}>-</button>
<span>{item.quantity}</span>
<button onClick={() => updateQuantity(item.id, item.quantity + 1)}>+</button>
</div>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
<div className="cart-footer">
<strong>Total: ${total.toFixed(2)}</strong>
<button className="checkout-btn">Checkout</button>
</div>
</div>
);
}
export default Cart;
// src/components/ProductCard.jsx
import { useCart } from '../context/CartContext';
function ProductCard({ product }) {
const { items, addItem } = useCart();
const isInCart = items.some(item => item.id === product.id);
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button
onClick={() => addItem(product)}
className={isInCart ? 'in-cart' : ''}
>
{isInCart ? '✓ In Cart' : 'Add to Cart'}
</button>
</div>
);
}
export default ProductCard;
Context API vs Redux: When to Choose What
A common question: should you use Context API or Redux? The honest answer is it depends on your application's complexity and team size. Here's an objective comparison:
| Criteria | Context API | Redux (+ Redux Toolkit) |
|---|---|---|
| Setup complexity | Minimal — built into React | Moderate — extra packages |
| Boilerplate | Very little | Reduced with Redux Toolkit |
| DevTools | React DevTools only | Redux DevTools (time-travel) |
| Performance at scale | Requires careful splitting | Built-in selector optimization |
| Middleware (async) | Manual (useReducer + custom) | Redux Thunk / RTK Query |
| State persistence | Manual implementation | redux-persist library |
| Testing | Simple — wrap in Provider | More setup required |
| Best for | Small-medium apps, isolated feature state | Large apps, complex state interactions |
Context API does not have built-in selectors. If component A only needs user.name but the context value includes user.preferences, A will still re-render when preferences change. Redux with useSelector re-renders a component only when the selected slice of state changes. For high-frequency updates (e.g., real-time feeds, animations), Redux or Zustand may be better choices.
Composing Multiple Providers
Real applications use multiple contexts. Rather than nesting them manually, create a single AppProviders wrapper to keep your root clean:
import { AuthProvider } from '../context/AuthContext';
import { ThemeProvider } from '../context/ThemeContext';
import { CartProvider } from '../context/CartContext';
import { NotificationsProvider } from '../context/NotificationsContext';
// Compose providers into a single wrapper — order matters!
// Outer providers are available to inner providers.
export function AppProviders({ children }) {
return (
<ThemeProvider>
<AuthProvider>
<NotificationsProvider>
<CartProvider>
{children}
</CartProvider>
</NotificationsProvider>
</AuthProvider>
</ThemeProvider>
);
}
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { AppProviders } from './providers/AppProviders';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AppProviders>
<App />
</AppProviders>
</BrowserRouter>
</React.StrictMode>
);
Testing Components that Use Context
Testing is straightforward: wrap the component under test in the Provider with a controlled value. You can also mock the context value directly with a helper wrapper:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AuthProvider } from '../../context/AuthContext';
import UserCard from '../UserCard';
// Helper: render with providers
function renderWithAuth(ui, { user = null } = {}) {
return render(
<AuthContext.Provider value={{ user, isAuthenticated: !!user }}>
{ui}
</AuthContext.Provider>
);
}
describe('UserCard', () => {
test('renders user name and email when authenticated', () => {
const mockUser = { name: 'Mayur Dabhi', email: 'mayur@example.com' };
renderWithAuth(<UserCard />, { user: mockUser });
expect(screen.getByText('Mayur Dabhi')).toBeInTheDocument();
expect(screen.getByText('mayur@example.com')).toBeInTheDocument();
});
test('renders nothing when user is null', () => {
const { container } = renderWithAuth(<UserCard />, { user: null });
expect(container).toBeEmptyDOMElement();
});
});
Context API Best Practices Summary
| Practice | Why It Matters |
|---|---|
| Export a custom hook, not the context | Enables guard clause and cleaner API for consumers |
| Co-locate context + provider + hook in one file | Reduces import clutter and keeps related code together |
Use useMemo for context value |
Prevents unnecessary consumer re-renders |
| Split contexts by update frequency | Only components needing that specific data re-render |
| Separate state and dispatch contexts | Action-only components don't re-render on state change |
| Compose providers into AppProviders | Keeps root clean, easy to add/remove providers |
| Don't use Context for server state | Use React Query or SWR for data fetching/caching |
Key Takeaways
The React Context API is a powerful, zero-dependency solution for global state in React applications. Here's what you should carry forward:
What We Covered
- Prop drilling problem: Context eliminates the need to pass props through intermediary components
- Core API:
createContext+Provider+useContextare all you need - Custom hook pattern: Always export a custom hook with a guard clause instead of exposing the context directly
- Performance: Use
useMemo, split contexts, and separate state/dispatch contexts to prevent unnecessary re-renders - useReducer: Pairs naturally with Context for complex state that has multiple transition types
- Testing: Wrap component under test in a Provider with controlled values — simple and effective
- When to upgrade: Switch to Redux/Zustand when you need time-travel debugging, fine-grained selectors, or complex middleware
"Context is primarily used when some data needs to be accessible by many components at different nesting levels. Apply it sparingly because it makes component reuse more difficult."
— React Documentation
React Context API strikes the right balance for most applications: no extra dependencies, minimal boilerplate, and enough power to handle themes, auth, carts, and feature flags with ease. Start with Context, measure your performance, and only reach for heavier tools when the data clearly demands it.