A Beginner-Friendly Guide to the Context API in React
You've built a few React components. Things are going well.
Then you need to pass some data from your top-level component down to a component buried three levels deep.
So you pass it as a prop. Then pass it again. And again.
The components in between don't even USE that data. They're just passing it along like a game of hot potato.
This is called prop drilling. And it's annoying.
That's exactly what the Context API solves.
The Prop Drilling Problem
Let me show you why this gets messy fast.
Say you're building a theme switcher. You need theme data in your Header (for the logo color) and in your Footer (for background color).
Without Context:
function App() {
const [theme, setTheme] = useState('light')
return (
)
}
function Main({ theme }) {
return (
)
}
function Sidebar({ theme }) {
return (
)
}
// Nav finally uses it
function Nav({ theme }) {
return
}
Look at all that passing! Main and Sidebar don't care about theme. They're just middlemen.
Now imagine you need to add more data. User info. Language preference. Notifications.
Every component becomes a prop relay station.
How Context API Works
Context is like a broadcast system. Instead of passing data through every level, you broadcast it once. Any component can tune in and listen.
Here's the same theme example with Context:
import { createContext, useContext, useState } from 'react'
// 1. Create a context
const ThemeContext = createContext()
// 2. Create a provider component
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light')
return (
{children}
)
}
// 3. Create a hook for easy access
function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within ThemeProvider')
}
return context
}
// 4. Wrap your app
function App() {
return (
)
}
// 5. Use it anywhere, without prop drilling!
function Nav() {
const { theme } = useTheme()
return
}
function ThemeToggle() {
const { theme, setTheme } = useTheme()
return (
)
}
See the difference? Main and Sidebar completely disappear from the data flow. Only the components that actually need the theme access it directly.
Building Your First Context: A Real Example
Let's build something practical-a shopping cart context.
Step 1: Create the Context
import { createContext, useContext, useState } from 'react'
const CartContext = createContext()
Simple. This creates an empty context object.
Step 2: Build the Provider
This is where your state lives.
function CartProvider({ children }) {
const [items, setItems] = useState([])
const addItem = (product) => {
setItems(prev => {
// Check if item already exists
const existing = prev.find(item => item.id === product.id)
if (existing) {
// Increase quantity
return prev.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
// Add new item
return [...prev, { ...product, quantity: 1 }]
})
}
const removeItem = (productId) => {
setItems(prev => prev.filter(item => item.id !== productId))
}
const updateQuantity = (productId, quantity) => {
if (quantity <= 0) {
removeItem(productId)
return
}
setItems(prev =>
prev.map(item =>
item.id === productId ? { ...item, quantity } : item
)
)
}
const clearCart = () => setItems([])
const total = items.reduce((sum, item) => {
return sum + (item.price * item.quantity)
}, 0)
const itemCount = items.reduce((count, item) => count + item.quantity, 0)
const value = {
items,
addItem,
removeItem,
updateQuantity,
clearCart,
total,
itemCount,
}
return (
{children}
)
}
This provider:
- Holds the cart state
- Provides functions to modify it
- Calculates derived values (total, itemCount)
- Makes everything available through
value
Step 3: Create a Custom Hook
This makes the API nicer to use.
function useCart() {
const context = useContext(CartContext)
if (context === undefined) {
throw new Error('useCart must be used within CartProvider')
}
return context
}
The error check is important. It catches mistakes where you forget to wrap components in the provider.
Step 4: Wrap Your App
function App() {
return (
)
}
Everything inside CartProvider can now access the cart.
Step 5: Use It Anywhere
function ProductCard(props) {
const product = props.product
const addItem = useCart((state) => state.addItem)
return (
Product Name
Price
)
}
function CartButton() {
const itemCount = useCart((state) => state.itemCount)
return (
)
}
function CartSummary() {
const items = useCart((state) => state.items)
const total = useCart((state) => state.total)
const removeItem = useCart((state) => state.removeItem)
return (
Cart items display here
Total amount
)
}
Look how clean this is! No props. No drilling. Just direct access to cart data and functions.
Another Example: Authentication Context
This is probably the most common use case for Context.
const AuthContext = createContext()
function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
// Check if user is logged in on mount
useEffect(() => {
const checkAuth = async () => {
try {
const response = await fetch('/api/me')
if (response.ok) {
const userData = await response.json()
setUser(userData)
}
} catch (error) {
console.error('Auth check failed:', error)
} finally {
setLoading(false)
}
}
checkAuth()
}, [])
const login = async (email, password) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (response.ok) {
const userData = await response.json()
setUser(userData)
return { success: true }
}
return { success: false, error: 'Invalid credentials' }
}
const logout = async () => {
await fetch('/api/logout', { method: 'POST' })
setUser(null)
}
const value = {
user,
loading,
login,
logout,
isAuthenticated: !!user,
}
return (
{children}
)
}
function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}
// Usage
function LoginButton() {
const { isAuthenticated, logout } = useAuth()
if (isAuthenticated) {
return
}
return
}
function Profile() {
const { user, loading } = useAuth()
if (loading) return Loading...
if (!user) return Please log in
return Welcome, {user.name}!
}
Now any component can check auth status or trigger login/logout. No prop drilling required.
Best Practices
1. One Context, One Purpose
Don't dump everything into a single context.
Bad:
const AppContext = createContext() // user, theme, cart, everything!
Good:
const AuthContext = createContext()
const ThemeContext = createContext()
const CartContext = createContext()
2. Always Create a Custom Hook
Makes the API cleaner and catches mistakes.
function useCart() {
const context = useContext(CartContext)
if (!context) {
throw new Error('useCart must be used within CartProvider')
}
return context
}
3. Keep Provider Logic Clean
If your provider gets complex, extract logic to custom hooks.
function CartProvider({ children }) {
const cart = useCartLogic() // Custom hook with all the logic
return (
{children}
)
}
4. Don't Overuse Context
Context is great for:
- Theme data
- User authentication
- Language/locale
- Global UI state (modals, notifications)
Context is NOT great for:
- Data that changes frequently
- Data only used by a few components
- Complex state logic (use Zustand or Redux instead)
Common Mistakes to Avoid
Mistake 1: Forgetting the Provider
// This will error!
function MyComponent() {
const { theme } = useTheme() // Error: used outside provider
return ...
}
Always wrap your app (or the part that needs it) in the provider.
Mistake 2: Creating New Objects in Value
// Bad - creates new object on every render
Every render creates a new object, causing all consumers to re-render.
Fix: Use useMemo or put it in stateconst value = useMemo(() => ({ theme, setTheme }), [theme])
Mistake 3: Too Many Re-renders
If your context value changes often, all consumers re-render.
Solution: Split contexts or use state management libraries for frequently-changing data.When Context Clicks
I remember building my first real app with Context. A dashboard with user data, settings, and notifications.
Before Context, I was passing props through 7 components. It was a nightmare to refactor.
With Context:
Any component could access what it needed. Adding new features became easy. The code felt clean again.
That's when Context clicked for me.
The Bottom Line
Context API solves prop drilling. That's its job.
It's not a full state management solution. It's not Redux. It's not Zustand.
It's a way to share data without passing props.
For theme, auth, and UI state? Perfect.
For complex app state? Consider other options.
But for most projects, Context is exactly what you need.
---
Want to see Context in action? Check out my guide on building authentication in Next.js using Context API.Want articles like this in your inbox?
Join developers and founders who get practical insights on frontend, SaaS, and building better products.
Written by Salman Izhar
Frontend Developer specializing in React, Next.js, and building high-converting web applications.
Learn More