π The useContext Hook
Passing a value from the top of your component tree down to a deeply nested child by threading it through every component in between is tedious and brittle. useContext lets any component read shared data directly β no matter how deep it sits β turning "prop drilling" into a single, clean subscription.
Week 5 · Monday: React Hooks Deep Dive · Lecture 1
π― Learning Objectives
By the end of this lesson, you will be able to:
- Recognize prop drilling and explain why it becomes a maintenance problem
- Create a Context with
createContextand expose it through a Provider component - Read context values in any descendant with the
useContexthook - Wrap a context in a custom hook that guards against missing Providers
- Decide what genuinely belongs in context versus ordinary props or local state
- Anticipate Context re-render behavior and split contexts to keep it fast
Estimated Time: 60 minutes
Practice: Build a theme Provider plus a global shopping-cart context, each consumed through its own custom hook.
In This Lesson
The Problem: Prop Drilling
Imagine a busy pizza restaurant. The customer's order needs to reach the counter, the kitchen, and the delivery desk. Now imagine the only way to share the order was to hand a copy to every single person standing between the counter and the kitchen β even people who have nothing to do with it. That is prop drilling: passing a prop through layers of components that don't use it, purely to reach one that does.
Here's a theme value being forced down through components that only exist to relay it:
// Without context β prop drilling. Every layer must forward `theme`.
function App() {
const [theme, setTheme] = useState('light');
return (
<div>
<Navbar theme={theme} /> {/* Navbar doesn't use theme⦠*/}
<MainContent theme={theme} />
<Footer theme={theme} />
</div>
);
}
function Navbar({ theme }) {
// β¦it only forwards it downward.
return (
<nav>
<Logo theme={theme} />
<NavLinks theme={theme} />
</nav>
);
}
function NavLinks({ theme }) {
return (
<ul>
<NavLink theme={theme} text="Home" />
<NavLink theme={theme} text="About" />
</ul>
);
}
// NavLink is the ONLY component that actually reads `theme`.
owns theme state] --> B[Navbar] A --> C[MainContent] A --> D[Footer] B --> E[Logo] B --> F[NavLinks] F --> G[NavLink
finally uses theme] F --> H[NavLink
finally uses theme]
theme travels through Navbar and NavLinks, which never use it. Add a fourth or fifth layer and every one of them has to be edited whenever the shared data changes. This is exactly the kind of coupling that makes refactoring painful β and it is what context was designed to remove.
The Solution: Context & useContext
Context is like a warehouse with a public address. One component stocks the warehouse (the Provider), and any descendant can walk up and read from it directly (the consumer) β no relaying required. The data no longer passes through the components in between; it teleports straight to whoever asks.
import { createContext, useContext, useState } from 'react';
// 1) Create the context. The argument is the default value used
// ONLY when a component reads it with no Provider above it.
const ThemeContext = createContext(null);
// 2) A Provider component owns the state and shares it.
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () =>
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
// `value` is what every consumer receives.
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// 3) Any descendant consumes it β no props threaded through.
function NavLink({ text }) {
const { theme } = useContext(ThemeContext);
return <li className={`nav-link ${theme}`}>{text}</li>;
}
// 4) Wrap the tree once, at the top.
function App() {
return (
<ThemeProvider>
<Navbar />
<MainContent />
<Footer />
</ThemeProvider>
);
}
Anatomy of a Context
A context has exactly three moving parts. Keep them straight and the rest is easy.
| Piece | What it is | Where it lives |
|---|---|---|
createContext(default) | The channel itself; returns an object with a .Provider | Module top level, exported |
<Ctx.Provider value={β¦}> | Broadcasts value to every descendant | High in the tree, wrapping children |
useContext(Ctx) | Reads the nearest Provider's value | Inside any descendant component |
β οΈ The default value is a fallback, not the norm
The argument to createContext is used only when a component calls useContext with no matching Provider above it. In a correctly wrapped app you'll almost never hit it β which is why passing null and throwing a helpful error (next section) beats a silent, misleading default.
Wrapping Context in a Custom Hook
Calling useContext(ThemeContext) everywhere means importing the context object into every file and hoping a Provider exists above you. A tiny custom hook fixes both problems: it hides the context object and fails loudly if you forgot the Provider.
// A custom hook is just a function starting with "use"
// that calls other hooks. This one guards the context.
function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used inside a <ThemeProvider>');
}
return context;
}
// Consumers never touch ThemeContext directly:
function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
{theme === 'light' ? 'π Dark' : 'π Light'} mode
</button>
);
}
β Why this pattern is standard
The custom-hook wrapper gives you one import (useTheme) instead of two (useContext + ThemeContext), a clear error the moment a Provider is missing, and a single place to add logic later. You'll see this exact shape in nearly every production React codebase.
Real-World Example: A Shopping Cart
Let's build a global cart that any product card or header badge can reach. We combine context (for sharing) with useReducer (for structured updates β the subject of the next lesson). Don't worry about the reducer details yet; focus on how the cart is shared.
// CartContext.jsx
import { createContext, useContext, useReducer } from 'react';
const CartContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
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
),
};
default:
return state;
}
}
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
// Derived value β recomputed on each render, never stored in state.
const cartTotal = state.items.reduce(
(total, item) => total + item.price * item.quantity,
0
);
const value = {
items: state.items,
cartTotal,
addToCart: (product) => dispatch({ type: 'ADD_ITEM', payload: product }),
removeFromCart: (id) => dispatch({ type: 'REMOVE_ITEM', payload: id }),
updateQuantity: (id, quantity) =>
dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }),
};
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
// The guarded custom hook.
export function useCart() {
const context = useContext(CartContext);
if (context === null) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
Now any component β however deep β reads or updates the cart in one line:
function Product({ product }) {
const { addToCart } = useCart();
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>${product.price.toFixed(2)}</p>
<button onClick={() => addToCart(product)}>Add to Cart</button>
</div>
);
}
function CartIcon() {
const { items } = useCart();
const count = items.reduce((sum, item) => sum + item.quantity, 0);
return (
<div className="cart-icon">
π {count > 0 && <span className="badge">{count}</span>}
</div>
);
}
π‘ Notice what we did not store
cartTotal and count are derived from items on every render. Storing them in state would risk them drifting out of sync. Rule: keep the minimal source of truth in state, compute the rest.
Common Context Patterns
Authentication context
Auth state (the current user plus login/logout) is the textbook use for context β nearly every screen needs it.
const AuthContext = createContext(null);
function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem('token');
if (!token) { setLoading(false); return; }
fetchUser(token)
.then(setUser)
.finally(() => setLoading(false));
}, []);
const login = async (email, password) => {
const res = await authAPI.login(email, password);
setUser(res.user);
localStorage.setItem('token', res.token);
};
const logout = () => {
setUser(null);
localStorage.removeItem('token');
};
if (loading) return <LoadingSpinner />;
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
}
Internationalization (i18n)
A language context exposes a t() translator that any component can call.
const LanguageContext = createContext(null);
const translations = {
en: { welcome: 'Welcome', addToCart: 'Add to Cart' },
es: { welcome: 'Bienvenido', addToCart: 'AΓ±adir al carrito' },
};
function LanguageProvider({ children }) {
const [language, setLanguage] = useState('en');
// Look up the key; fall back to the key itself if missing.
const t = (key) => translations[language][key] ?? key;
return (
<LanguageContext.Provider value={{ language, setLanguage, t }}>
{children}
</LanguageContext.Provider>
);
}
function WelcomeMessage() {
const { t } = useContext(LanguageContext);
return <h1>{t('welcome')}!</h1>;
}
Re-renders & Performance
Context is powerful, but it has one behavior you must respect: when a Provider's value changes, every consumer re-renders β even consumers that only read a part of the value they didn't change.
Two habits keep this fast:
β οΈ 1. Don't create a fresh object literal every render for stable data
// β A NEW object every render β all consumers re-render every time
<ThemeContext.Provider value={{ theme, toggleTheme }}>
// β
Memoize when the value is expensive to recompute or widely consumed
const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
<ThemeContext.Provider value={value}>
For a small app the object literal is fine. For a large tree with many consumers, useMemo avoids needless re-renders.
β 2. Split one big context into focused ones
// β One mega-context: a theme flip re-renders cart consumers too
const AppContext = createContext({ user, theme, cart, notifications });
// β
Focused contexts: each consumer only re-renders for its own slice
const UserContext = createContext(null);
const ThemeContext = createContext(null);
const CartContext = createContext(null);
A component that only reads ThemeContext won't re-render when the cart changes. Separation of concerns doubles as a performance win.
Practice & Quiz
ποΈ Exercise 1: A guarded theme hook
Goal: Complete a ThemeProvider and a useTheme() custom hook that throws if used outside the Provider.
const ThemeContext = createContext(null);
function ThemeProvider({ children }) {
// TODO: hold theme state, expose { theme, toggleTheme }
}
function useTheme() {
// TODO: read context, throw if null, otherwise return it
}
π‘ Hint
Use useState('light') for the theme and a toggleTheme that flips it with the functional updater. In useTheme, read with useContext, compare to null, then throw new Error(...).
β Solution
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () =>
setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
ποΈ Exercise 2: A notification context
Goal: Build a NotificationProvider that lets any component call notify('Saved!'), stores toasts in an array, and auto-removes each one after 3 seconds.
β Solution
const NotificationContext = createContext(null);
function NotificationProvider({ children }) {
const [toasts, setToasts] = useState([]);
const notify = (message) => {
const id = Date.now();
setToasts(prev => [...prev, { id, message }]);
// Auto-dismiss after 3s
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3000);
};
return (
<NotificationContext.Provider value={{ toasts, notify }}>
{children}
</NotificationContext.Provider>
);
}
export function useNotify() {
const ctx = useContext(NotificationContext);
if (!ctx) throw new Error('useNotify needs a NotificationProvider');
return ctx;
}
π― Quick Quiz
Question 1: What problem does useContext primarily solve?
Question 2: When is the argument passed to createContext(defaultValue) actually used?
Question 3: Why split one large context into several focused ones?
Best Practices & Pitfalls
β Do
- Wrap each context in a guarded custom hook (
useCart,useAuth) - Keep the minimal source of truth in state and derive the rest
- Split contexts by concern β auth, theme, cart β for clarity and fewer re-renders
- Reserve context for truly app-wide data: auth, theme, language, cart
β Don't
- Put fast-changing, local UI state (a single input's value) in context
- Bundle everything into one mega-context that re-renders the whole tree
- Rely on the
createContextdefault instead of a real Provider - Forget that changing the Provider
valuere-renders all consumers
β οΈ Context is not a state manager
Context is a transport mechanism β it moves a value from a Provider to consumers. The state itself still comes from useState or useReducer. For very large apps with heavy cross-cutting updates, a dedicated library (Redux Toolkit, Zustand) may serve better; context shines for moderate, app-wide data.
Summary
π Key Takeaways
- Prop drilling β threading props through components that don't use them β is what context removes
- A context has three parts:
createContext, a Provider, anduseContext - Wrap each context in a guarded custom hook so a missing Provider fails loudly
- Derive values like totals from state instead of storing them
- Changing a Provider's
valuere-renders every consumer β memoize and split contexts to stay fast
π Additional Resources
- react.dev β useContext reference
- react.dev β Passing Data Deeply with Context
- react.dev β createContext reference
π What's Next?
Our cart already leaned on a reducer to structure its updates. Next we slow down and master that tool on its own: the useReducer hook β dispatching actions to a pure reducer for predictable, complex state.
π Great work!
You can now share state across an entire app without a single drilled prop. That's the backbone of every real React project.