Context API: The Prop Drilling Escape Hatch
Master React Context API to share state across components without prop drilling. Learn createContext, Provider pattern, useContext hook, and best practices for theme switching, authentication, and shopping carts. Includes performance tips and when to use Context vs Redux.
The Problem That Made Me Question Everything
In my last post, I was riding high on custom hooks. Extracting logic, sharing code, feeling like a real React developer.
Then I built a dashboard with a theme toggle.
Sounds simple, right? A button that switches between light and dark mode. The problem? That button was in the navbar, but the theme needed to affect… everything.
function App() {
const [theme, setTheme] = useState('light');
return (
<div className={theme}>
<Navbar theme={theme} setTheme={setTheme}>
<NavLinks theme={theme}>
<ThemeToggle theme={theme} setTheme={setTheme} /> {/* Finally! */}
</NavLinks>
</Navbar>
<Main theme={theme}>
<Sidebar theme={theme}>
<SidebarItem theme={theme} />
<SidebarItem theme={theme} />
</Sidebar>
<Content theme={theme}>
<Card theme={theme} />
<Card theme={theme} />
<Card theme={theme}>
<CardHeader theme={theme} />
<CardBody theme={theme}>
<Button theme={theme} /> {/* 6 levels deep! */}
</CardBody>
</Card>
</Content>
</Main>
</div>
);
}
I was passing theme through every. Single. Component.
Most of these components didn’t even USE the theme. They were just passing it along like a bucket brigade at a fire.
This is prop drilling. And it’s miserable.
Enter: React Context
Context lets you pass data through the component tree without manually passing props at every level.
Think of it like a radio broadcast. Instead of passing a message person-to-person-to-person, you broadcast it and anyone with a radio can tune in.
// Create the "radio station"
const ThemeContext = React.createContext('light');
// Broadcast from the top
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<div className={theme}>
<Navbar />
<Main />
</div>
</ThemeContext.Provider>
);
}
// Tune in from anywhere!
function Button() {
const { theme } = useContext(ThemeContext);
return <button className={`btn-${theme}`}>Click me</button>;
}
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme === 'light' ? '🌙' : '☀️'}
</button>
);
}
No more prop drilling! Navbar, Main, Sidebar, Content — none of them need to know about theme. Only the components that actually USE it opt in.
How Context Actually Works
Step 1: Create Context
const MyContext = React.createContext(defaultValue);
The defaultValue is used when a component tries to consume context but there’s no Provider above it. It’s a fallback, not an initial value.
Step 2: Provide Context
<MyContext.Provider value={actualValue}>
<ChildComponents />
</MyContext.Provider>
Everything inside the Provider can access actualValue.
Step 3: Consume Context
function DeepChild() {
const value = useContext(MyContext);
return <div>{value}</div>;
}
No matter how deep DeepChild is nested, it gets the value from the nearest Provider above it.
The Pattern That Changed Everything
After using Context for a while, I settled on a pattern that I now use everywhere:
// ThemeContext.jsx
import { createContext, useContext, useState } from 'react';
// 1. Create context
const ThemeContext = createContext(null);
// 2. Create provider component
export 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>
);
}
// 3. Create custom hook for consuming
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Now using it is dead simple:
// App.jsx
import { ThemeProvider } from './ThemeContext';
function App() {
return (
<ThemeProvider>
<Navbar />
<Main />
</ThemeProvider>
);
}
// Any component, anywhere
import { useTheme } from './ThemeContext';
function SomeDeepComponent() {
const { theme, toggleTheme } = useTheme();
// ...
}
The error in useTheme catches a common mistake: using the hook outside a Provider. Future me has thanked past me for this many times.
Real-World Context Examples
Authentication Context
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check if user is logged in on mount
checkAuthStatus().then(user => {
setUser(user);
setLoading(false);
});
}, []);
const login = async (credentials) => {
const user = await loginAPI(credentials);
setUser(user);
};
const logout = async () => {
await logoutAPI();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Now any component can check if the user is logged in:
function NavBar() {
const { user, logout } = useAuth();
return (
<nav>
{user ? (
<>
<span>Hello, {user.name}</span>
<button onClick={logout}>Logout</button>
</>
) : (
<Link to="/login">Login</Link>
)}
</nav>
);
}
Shopping Cart Context
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
const addItem = (product) => {
setItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
};
const removeItem = (productId) => {
setItems(prev => prev.filter(item => item.id !== productId));
};
const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<CartContext.Provider value={{ items, addItem, removeItem, total }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
}
Product page adds to cart:
function ProductCard({ product }) {
const { addItem } = useCart();
return (
<div>
<h3>{product.name}</h3>
<p>${product.price}</p>
<button onClick={() => addItem(product)}>Add to Cart</button>
</div>
);
}
Header shows cart count:
function Header() {
const { items } = useCart();
return (
<header>
<Link to="/cart">
Cart ({items.reduce((sum, item) => sum + item.quantity, 0)})
</Link>
</header>
);
}
No prop drilling. No Redux (yet). Just React.
Things That Bit Me
1. Context Re-renders All Consumers
Here’s the gotcha that cost me a day of debugging:
function MyProvider({ children }) {
const [count, setCount] = useState(0);
const [name, setName] = useState('');
// 🚨 New object every render!
return (
<MyContext.Provider value={{ count, setCount, name, setName }}>
{children}
</MyContext.Provider>
);
}
Every time count changes, EVERY component consuming this context re-renders — even if they only use name.
The fix? Split contexts or memoize:
// Option 1: Split contexts
<CountContext.Provider value={{ count, setCount }}>
<NameContext.Provider value={{ name, setName }}>
{children}
</NameContext.Provider>
</CountContext.Provider>
// Option 2: Memoize the value
const value = useMemo(() => ({ count, setCount, name, setName }), [count, name]);
return <MyContext.Provider value={value}>{children}</MyContext.Provider>;
2. Context Is Not Global State
A common misconception: Context replaces Redux.
It doesn’t. Not exactly.
Context is for passing data down. It doesn’t give you:
- Time-travel debugging
- Middleware
- Centralized actions
- DevTools
For simple apps, Context is enough. For complex state with lots of actions and reducers, you might still want Redux or Zustand.
3. Default Values Are Tricky
const ThemeContext = createContext('light'); // Default is 'light'
function App() {
return (
// Oops, no Provider!
<Navbar />
);
}
function Navbar() {
const theme = useContext(ThemeContext); // Gets 'light' (the default)
// ...
}
This doesn’t error. It silently uses the default. That’s why I always use:
const ThemeContext = createContext(null);
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
Now forgetting the Provider throws a clear error instead of silently breaking.
Composing Multiple Providers
Real apps often need multiple contexts. This can get pyramid-shaped:
function App() {
return (
<ThemeProvider>
<AuthProvider>
<CartProvider>
<NotificationProvider>
<RouterProvider>
<Content />
</RouterProvider>
</NotificationProvider>
</CartProvider>
</AuthProvider>
</ThemeProvider>
);
}
Some people create a Providers component:
function Providers({ children }) {
return (
<ThemeProvider>
<AuthProvider>
<CartProvider>
<NotificationProvider>
{children}
</NotificationProvider>
</CartProvider>
</AuthProvider>
</ThemeProvider>
);
}
function App() {
return (
<Providers>
<Content />
</Providers>
);
}
Or use a compose utility:
const providers = [ThemeProvider, AuthProvider, CartProvider, NotificationProvider];
function Providers({ children }) {
return providers.reduceRight(
(acc, Provider) => <Provider>{acc}</Provider>,
children
);
}
Honestly? I usually just live with the pyramid. It’s not that bad.
When to Use Context vs. Props
Use props when:
- Data only needs to go 1-2 levels deep
- The component is genuinely configurable by its parent
- You want explicit data flow that’s easy to trace
Use context when:
- Data needs to pass through many levels
- Many unrelated components need the same data
- You’re building a “global” feature (theme, auth, localization)
Don’t use context when:
- You’re just lazy about passing props
- The data changes frequently and many components consume it (performance issue)
- A simpler solution exists
What I Wish I’d Known Earlier
-
Start with props. Only reach for Context when prop drilling actually hurts.
-
Split contexts by update frequency. Things that change together should be in the same context. Things that change independently should be separate.
-
The custom hook pattern is essential. Always create a
useXhook for your context. It’s cleaner and catches missing providers. -
Context + useReducer is powerful. For complex state logic, combine them:
function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
- It’s okay to have many contexts. Don’t try to put everything in one mega-context.
The Journey Continues
Context solved my prop drilling problem. Combined with custom hooks, I had a powerful toolkit for managing state in React apps.
But as my apps grew larger, I started noticing performance issues. Components re-rendering when they shouldn’t. Laggy interactions. Janky scrolling.
Time to learn about useMemo, useCallback, and React.memo.
P.S. — If you’re reading this in 2020 and wondering about Redux, don’t worry. I still use it for complex apps. But for many projects, Context + hooks is genuinely enough. The React team wasn’t kidding when they said hooks would change how we think about state management.
Saurav Sitaula
Software Architect • Nepal