Context solves the "prop drilling" problem — passing the same value through 5 layers of components just to reach one deep child. Here's how to use it correctly, and when not to.
The Problem Context Solves
// Without Context — theme prop drilled through every layer
function App() {
const [theme, setTheme] = useState('light')
return <Layout theme={theme} setTheme={setTheme} />
}
function Layout({ theme, setTheme }: any) {
return <Sidebar theme={theme} setTheme={setTheme} />
}
function Sidebar({ theme, setTheme }: any) {
return <ThemeToggle theme={theme} setTheme={setTheme} />
}
// ThemeToggle finally uses it — after drilling through 3 unnecessary layers
function ThemeToggle({ theme, setTheme }: any) {
return <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>Toggle</button>
}
With Context, ThemeToggle reads the value directly, no drilling needed.
Creating a Context
// src/contexts/ThemeContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react'
interface ThemeContextType {
theme: 'light' | 'dark'
toggleTheme: () => void
}
// 1. Create context with a default value (used if no Provider wraps the component)
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
toggleTheme: () => {}
})
// 2. Create provider component
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light')
function toggleTheme() {
setTheme(t => t === 'light' ? 'dark' : 'light')
document.documentElement.setAttribute('data-bs-theme', theme === 'light' ? 'dark' : 'light')
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
)
}
// 3. Custom hook — cleaner than calling useContext directly everywhere
export function useTheme() {
return useContext(ThemeContext)
}
// src/main.tsx — wrap the app with the provider
import { ThemeProvider } from './contexts/ThemeContext'
ReactDOM.createRoot(document.getElementById('root')!).render(
<ThemeProvider>
<App />
</ThemeProvider>
)
// Any component, any depth — reads theme directly
function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
return (
<button
onClick={toggleTheme}
className={`btn btn-sm ${theme === 'dark' ? 'btn-light' : 'btn-dark'}`}
>
{theme === 'dark' ? '☀️ Light' : '🌙 Dark'}
</button>
)
}
Auth Context — Real-World Pattern
// src/contexts/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
interface AuthContextType {
user: User | null
loading: boolean
login: (email: string, password: string) => Promise<void>
logout: () => void
isAdmin: boolean
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(false)
async function login(email: string, password: string) {
setLoading(true)
try {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
headers: { 'Content-Type': 'application/json' }
})
const data = await res.json()
setUser(data.user)
localStorage.setItem('token', data.token)
} finally {
setLoading(false)
}
}
function logout() {
setUser(null)
localStorage.removeItem('token')
}
return (
<AuthContext.Provider value={{
user,
loading,
login,
logout,
isAdmin: user?.role === 'admin'
}}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
return ctx
}
// Usage — anywhere in the tree
function Navbar() {
const { user, logout, isAdmin } = useAuth()
return (
<nav className="navbar navbar-expand-lg bg-dark navbar-dark px-4">
<a className="navbar-brand fw-bold" href="/">LettStart</a>
<div className="ms-auto d-flex align-items-center gap-3">
{isAdmin && <span className="badge" style={{ background: '#fd4766' }}>Admin</span>}
{user ? (
<>
<span className="text-white small">{user.name}</span>
<button className="btn btn-sm btn-outline-light" onClick={logout}>Sign Out</button>
</>
) : (
<a href="/login" className="btn btn-sm text-white" style={{ background: '#fd4766' }}>Sign In</a>
)}
</div>
</nav>
)
}
Context + useReducer — For Complex State
When a context value has many related pieces of state, useReducer makes updates more predictable:
// src/contexts/CartContext.tsx
import { createContext, useContext, useReducer, ReactNode } from 'react'
interface CartItem { id: number; name: string; price: number; qty: number }
interface CartState { items: CartItem[]; open: boolean }
type CartAction =
| { type: 'ADD'; item: Omit<CartItem, 'qty'> }
| { type: 'REMOVE'; id: number }
| { type: 'UPDATE'; id: number; qty: number }
| { type: 'CLEAR' }
| { type: 'TOGGLE' }
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD': {
const exists = state.items.find(i => i.id === action.item.id)
return {
...state,
items: exists
? state.items.map(i => i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i)
: [...state.items, { ...action.item, qty: 1 }]
}
}
case 'REMOVE': return { ...state, items: state.items.filter(i => i.id !== action.id) }
case 'UPDATE': return { ...state, items: state.items.map(i => i.id === action.id ? { ...i, qty: action.qty } : i) }
case 'CLEAR': return { ...state, items: [] }
case 'TOGGLE': return { ...state, open: !state.open }
default: return state
}
}
const CartContext = createContext<{ state: CartState; dispatch: React.Dispatch<CartAction> } | null>(null)
export function CartProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(cartReducer, { items: [], open: false })
return <CartContext.Provider value={{ state, dispatch }}>{children}</CartContext.Provider>
}
export function useCart() {
const ctx = useContext(CartContext)
if (!ctx) throw new Error('useCart must be used inside CartProvider')
const { state, dispatch } = ctx
return {
items: state.items,
open: state.open,
total: state.items.reduce((s, i) => s + i.price * i.qty, 0),
count: state.items.reduce((s, i) => s + i.qty, 0),
addItem: (item: Omit<CartItem, 'qty'>) => dispatch({ type: 'ADD', item }),
removeItem: (id: number) => dispatch({ type: 'REMOVE', id }),
clear: () => dispatch({ type: 'CLEAR' }),
toggle: () => dispatch({ type: 'TOGGLE' }),
}
}
When Context Is the Wrong Tool
Context re-renders every component that calls useContext() whenever the context value changes — even if that component only uses a slice of the value that didn't change.
| Situation | Better tool |
|---|---|
| Theme, locale, current user | Context ✅ |
| Shopping cart (frequent updates) | Zustand or Redux Toolkit |
| Server data (templates, users list) | React Query / TanStack Query |
| URL-derived state | URL search params |
| Form state | React Hook Form |
| Sibling component communication | Lift state up to parent |
If you're running into performance issues with Context, look at Zustand first — it's 1KB, zero boilerplate, and only re-renders components that subscribe to the specific slice of state they use.
Frequently Asked Questions
Need a Full Bootstrap 5 Admin Dashboard?
Get a complete Angular 21 + Bootstrap 5 dashboard with 50+ components — built by the same team behind BootstrapPlanet.
Browse Templates →Use code FIRST30 for 30% off your first purchase.