Every React developer eventually runs into this problem: you have some data in a parent component, and a deeply nested child needs it. So you start passing props down. Parent → Child → Grandchild → Great-grandchild. And suddenly your code looks like a relay race where every component is just passing the baton without actually using it.
This is called prop drilling, and it's the exact problem the Context API was built to solve.
The best part? You don't need Redux, Zustand, Jotai, or any external library. Context is built into React. It's free. It's simple. And for most apps, it's all you need.
Let's build three real things with it.
What is Context and why does it exist?
Imagine you live in a building with 10 floors. The WiFi router is on the 1st floor. Without Context, every floor has to physically pass the WiFi cable to the next floor:
Floor 1 (has WiFi) → passes cable → Floor 2 → passes cable → Floor 3 → ... → Floor 10 (needs WiFi)
Floors 2 through 9 don't even need the internet. They're just holding the cable for Floor 10. That's prop drilling.
Context is like a WiFi signal. Floor 1 broadcasts it, and Floor 10 picks it up directly. No cables. No middlemen. Every floor in between doesn't even know it exists.
Floor 1 (broadcasts WiFi) ~~~~~ Floor 10 (connects directly)
In React terms: a parent component provides a value, and any deeply nested child can consume it — without any components in between needing to know about it.
The 3-step pattern
Every single use of Context follows the same three steps. Once you learn this pattern, you can build anything with it.
Step 1: Create the context
import { createContext } from 'react';
const ThemeContext = createContext('light'); // default value
This creates a "broadcast channel." The default value ('light') is only used if a component tries to read the context but there's no provider above it in the tree.
Step 2: Provide the context (wrap your app)
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext value={{ theme, setTheme }}>
<Header />
<MainContent />
<Footer />
</ThemeContext>
);
}
Note: In React 19+, you write directly. In older versions, you'd write . Both work, but the new syntax is cleaner.
Step 3: Consume the context (use it anywhere)
function ThemeToggle() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current: {theme}
</button>
);
}
That's it. Three steps. Create, provide, consume. Now let's build real things.
Project 1: Dark/Light theme switcher
This is the "hello world" of Context, and it's genuinely useful. We'll build a theme toggle that any component in the app can read and change.
File: ThemeContext.jsx
import { createContext, useContext, useState } from 'react';
// Step 1: Create
const ThemeContext = createContext(null);
// Custom hook (so consumers don't need to import both useContext and ThemeContext)
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// Step 2: Provider component
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext value={{ theme, toggleTheme }}>
{children}
</ThemeContext>
);
}
File: App.jsx
import { ThemeProvider, useTheme } from './ThemeContext';
function Header() {
const { theme, toggleTheme } = useTheme();
return (
<header style={{
background: theme === 'dark' ? '#1a1a2e' : '#ffffff',
color: theme === 'dark' ? '#e0e0e0' : '#1a1a2e',
padding: '16px 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<h1>My App</h1>
<button onClick={toggleTheme}>
{theme === 'dark' ? '☀️ Light' : '🌙 Dark'}
</button>
</header>
);
}
function Article() {
const { theme } = useTheme();
return (
<article style={{
background: theme === 'dark' ? '#16213e' : '#f8f9fa',
color: theme === 'dark' ? '#e0e0e0' : '#333',
padding: '24px',
borderRadius: '8px',
margin: '16px',
}}>
<h2>Welcome to the blog</h2>
<p>This component reads the theme from Context.
It didn't receive any props. The Header and this
Article both read from the same ThemeContext.</p>
</article>
);
}
export default function App() {
return (
<ThemeProvider>
<Header />
<Article />
</ThemeProvider>
);
}
What's happening here: App doesn't know about the theme. Header and Article both read it directly. If you add 20 more components between App and Article, nothing changes — the Article still reads the theme directly from Context. No prop drilling.
Project 2: Authentication context
This is the one you'll actually need in production. Almost every app needs to know: is the user logged in, and who are they?
File: AuthContext.jsx
import { createContext, useContext, useState } from 'react';
const AuthContext = createContext(null);
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const login = (email, password) => {
// In a real app, you'd call your API here
// For demo purposes, we'll simulate it
return new Promise((resolve) => {
setTimeout(() => {
setUser({
id: 1,
name: 'Abhishek',
email: email,
avatar: '👨💻',
});
resolve();
}, 1000);
});
};
const logout = () => {
setUser(null);
};
return (
<AuthContext value={{ user, login, logout, isLoggedIn: !!user }}>
{children}
</AuthContext>
);
}
File: App.jsx
import { AuthProvider, useAuth } from './AuthContext';
function Navbar() {
const { user, isLoggedIn, logout } = useAuth();
return (
<nav style={{ padding: '12px 24px', borderBottom: '1px solid #eee',
display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 'bold' }}>MyApp</span>
{isLoggedIn ? (
<div>
<span>{user.avatar} {user.name}</span>
<button onClick={logout} style={{ marginLeft: '12px' }}>
Logout
</button>
</div>
) : (
<span style={{ color: '#888' }}>Not logged in</span>
)}
</nav>
);
}
function LoginForm() {
const { login, isLoggedIn } = useAuth();
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
if (isLoggedIn) {
return <p style={{ padding: '24px' }}>✅ You're logged in!</p>;
}
const handleLogin = async () => {
setLoading(true);
await login(email, 'password123');
setLoading(false);
};
return (
<div style={{ padding: '24px' }}>
<h2>Login</h2>
<input
type="email"
placeholder="Your email"
value={email}
onChange={e => setEmail(e.target.value)}
style={{ padding: '8px', marginRight: '8px', width: '250px' }}
/>
<button onClick={handleLogin} disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</div>
);
}
function Dashboard() {
const { user, isLoggedIn } = useAuth();
if (!isLoggedIn) {
return <p style={{ padding: '24px', color: '#888' }}>
Please login to see your dashboard.
</p>;
}
return (
<div style={{ padding: '24px' }}>
<h2>Dashboard</h2>
<p>Welcome back, <strong>{user.name}</strong>!</p>
<p>Email: {user.email}</p>
</div>
);
}
export default function App() {
return (
<AuthProvider>
<Navbar />
<LoginForm />
<Dashboard />
</AuthProvider>
);
}
Why this works beautifully: The Navbar, LoginForm, and Dashboard are completely independent components. They don't pass any auth props between each other. Yet they all stay in sync — when LoginForm calls login(), the Navbar immediately shows the user's name, and the Dashboard shows the welcome message. Context keeps them all connected.
Project 3: Shopping cart
This one is slightly more complex and shows how Context handles real state management — adding items, removing items, and calculating totals.
File: CartContext.jsx
import { createContext, useContext, useState } from 'react';
const CartContext = createContext(null);
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
export function CartProvider({ children }) {
const [items, setItems] = useState([]);
const addItem = (product) => {
setItems(prev => {
const existing = prev.find(item => item.id === product.id);
if (existing) {
// Increase quantity if already in cart
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 totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
const clearCart = () => setItems([]);
return (
<CartContext value={{
items, addItem, removeItem, clearCart, totalItems, totalPrice
}}>
{children}
</CartContext>
);
}
File: App.jsx
import { CartProvider, useCart } from './CartContext';
const PRODUCTS = [
{ id: 1, name: 'React Handbook', price: 499, emoji: '📘' },
{ id: 2, name: 'Node.js Course', price: 799, emoji: '🟢' },
{ id: 3, name: 'Rust Cheatsheet', price: 199, emoji: '🦀' },
{ id: 4, name: 'VS Code Pro Tips', price: 299, emoji: '💻' },
];
function ProductCard({ product }) {
const { addItem } = useCart();
return (
<div style={{
border: '1px solid #e0e0e0', borderRadius: '8px',
padding: '16px', width: '200px',
}}>
<div style={{ fontSize: '32px' }}>{product.emoji}</div>
<h3>{product.name}</h3>
<p>₹{product.price}</p>
<button onClick={() => addItem(product)}>
Add to Cart
</button>
</div>
);
}
function ProductList() {
return (
<div style={{ padding: '24px' }}>
<h2>Products</h2>
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
{PRODUCTS.map(product => (
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
);
}
function CartSummary() {
const { items, removeItem, totalItems, totalPrice, clearCart } = useCart();
return (
<div style={{
padding: '24px', background: '#f8f9fa',
borderRadius: '8px', margin: '24px',
}}>
<h2>🛒 Cart ({totalItems} items)</h2>
{items.length === 0 ? (
<p style={{ color: '#888' }}>Your cart is empty.</p>
) : (
<>
{items.map(item => (
<div key={item.id} style={{
display: 'flex', justifyContent: 'space-between',
padding: '8px 0', borderBottom: '1px solid #eee',
}}>
<span>{item.emoji} {item.name} × {item.quantity}</span>
<span>
₹{item.price * item.quantity}
<button
onClick={() => removeItem(item.id)}
style={{ marginLeft: '8px', color: 'red' }}
>
✕
</button>
</span>
</div>
))}
<div style={{
marginTop: '16px', fontWeight: 'bold', fontSize: '18px'
}}>
Total: ₹{totalPrice}
</div>
<button onClick={clearCart} style={{ marginTop: '12px' }}>
Clear Cart
</button>
</>
)}
</div>
);
}
function CartIcon() {
const { totalItems } = useCart();
return (
<div style={{
position: 'fixed', top: '16px', right: '16px',
background: '#1a1a2e', color: 'white',
padding: '8px 16px', borderRadius: '20px',
}}>
🛒 {totalItems}
</div>
);
}
export default function App() {
return (
<CartProvider>
<CartIcon />
<ProductList />
<CartSummary />
</CartProvider>
);
}
The power of this pattern: CartIcon sits in the top-right corner of the page. ProductList is in the main content. CartSummary is at the bottom. They're completely separate components, yet when you click "Add to Cart" on a product, the cart icon updates, the summary updates, and the total recalculates — all through Context. Zero prop drilling. Zero external libraries.
The custom hook pattern
You might have noticed I created a custom useTheme(), useAuth(), and useCart() hook in every example. This isn't just for convenience — it's a best practice for three reasons:
1. Cleaner imports. Consumers just write useCart() instead of importing both useContext and CartContext.
2. Error boundaries. The hook throws a helpful error if someone forgets the Provider:
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within a CartProvider');
// Without this, you'd get a silent `undefined` and waste
// 30 minutes debugging why things don't work
}
return context;
}
3. Encapsulation. The actual CartContext object is never exported. No one can mess with it directly. The only way to interact with it is through the custom hook and the Provider. Clean API.
This three-file pattern — Context file (create + provider + hook), App (wraps in provider), Components (use the hook) — works for literally any shared state. Theme, auth, language, cart, notifications, modals — same pattern every time.
When Context is NOT enough
Context is powerful, but it's not the answer to everything. Here's where it falls short:
Frequent updates to large objects. When a context value changes, every component that reads that context re-renders. If your context holds 50 fields and you update one, all 50 consumers re-render. For highly dynamic data (like a real-time stock ticker updating every second), this becomes a performance issue.
Complex state transitions. If your state updates involve complex logic — like undo/redo, optimistic updates, or state machines — Context + useState gets messy. You'd want useReducer (still built into React) or an external library at that point.
Server state (API data). Context is great for client state (theme, auth, UI state). It's not great for server state (data from your API). For that, use TanStack Query or SWR — they handle caching, refetching, loading states, and error handling, which Context doesn't.
Context vs Redux vs Zustand — the honest answer
Here's my take, and I'll keep it simple:
Use Context when:
You're sharing state that doesn't change frequently (theme, auth, locale)
You have a small-to-medium app
You don't want any extra dependencies
The state logic is straightforward (no complex reducers)
Use Zustand when:
You need frequent updates without re-rendering everything
You want something simple but more powerful than Context
Your state is used across many unrelated components
You're building a medium-to-large app
Use Redux Toolkit when:
You need time-travel debugging, middleware, or dev tools
You have complex state transitions with many actions
Your team already knows Redux
You're building a large enterprise app
For most apps — especially when you're starting out or building side projects — Context is enough. Don't add Redux to a todo app. Don't add Zustand to a blog. Start with Context, and upgrade only when you feel the pain.
Wrapping up
The Context API is one of the most useful features in React, and it's criminally underused because everyone reaches for Redux first.
Here's the pattern one more time, because it's all you need to remember:
Create the context in its own file
Provide it by wrapping your app (or a section of it)
Consume it with a custom hook
Three files. Three steps. Works for theme, auth, cart, language, modals, notifications — anything that multiple components need to share.
Build with Context first. Add libraries later. Your bundle size (and your sanity) will thank you.
This is part of a series where I simplify React docs. Previous: React useEffect Explained Simply. Next up: useMemo, useCallback & React.memo — When to Actually Memoize.
Found this useful? Share it with a dev friend who's still prop-drilling everything. They'll thank you.
Comments (1)
Sign in to join the conversation.
good blogs really helpful
thanx