π Context API and Global State
Some data β the signed-in user, the color theme, the shopping cart β is needed all over your app. Threading it through props by hand gets painful fast. React's Context API lets any component "tune in" to shared state directly, and in this lesson you'll learn to wield it well without wrecking performance.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Explain prop drilling and how Context eliminates it
- Create a context with
createContext, provide it with a Provider, and consume it withuseContext - Wrap a context in a custom hook with a safety guard, the pattern used in production codebases
- Combine Context with
useReducerto manage complex global state - Diagnose and fix Context re-render performance problems, and decide when a state library is a better fit
Estimated Time: 35β45 minutes β’ Difficulty: Intermediate
Hands-on: Build a theme context with a custom useTheme hook and wire a toggle button to it.
In This Lesson
The Problem: Prop Drilling
In React, data flows down through props: a parent hands values to its children. That is clean when the data has a short trip to make. But imagine the signed-in user lives at the top of your app and is needed by a tiny UserMenu buried five levels deep. Every component in between has to accept user and pass it along β even though none of them use it.
This is prop drilling: pushing props through layers of components that don't care about them, just to reach the one that does.
Four components (Layout, Header, Navigation, UserMenu) become couriers for data they never read. The costs add up:
- Noise β every intermediate component's signature is cluttered with pass-through props.
- Fragility β add one new field and you edit five files.
- Reduced reuse β those middle components are now welded to a specific prop shape.
π‘ Not every shared value needs Context. Prop drilling through one or two levels is perfectly fine β it's explicit and easy to follow. Context earns its keep when data is truly "global" and travels far.
What Context Actually Is
Context lets a component broadcast a value to its entire subtree. Any descendant can read that value directly, no matter how deep, without a single prop being passed by hand.
π The Radio-Station Analogy
A Provider is a radio station broadcasting on a frequency. Any component inside it is within range and can "tune in" with useContext to receive whatever is currently on the air. Change what's broadcast and every tuned-in listener updates at once β no wires run between the station and each radio.
Good candidates for Context are values that many, scattered components need: the current theme, the authenticated user, the app's language, or UI flags like whether a sidebar is open.
Context in Three Steps
Every context follows the same rhythm: create it, provide it, consume it. Here is a complete theme example in modern React (function components + hooks).
Step 1 β Create the context
import { createContext, useContext, useState } from 'react';
// createContext returns an object holding a Provider (and legacy Consumer).
// The argument is a *default* value, used only when a component reads the
// context without a matching Provider above it.
const ThemeContext = createContext(null);
Step 2 β Provide a value
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () =>
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
// Everything inside <ThemeProvider> can now read { theme, toggleTheme }.
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
Step 3 β Consume it anywhere below
function ThemeButton() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<button onClick={toggleTheme}>
Switch to {theme === 'light' ? 'dark' : 'light'} mode
</button>
);
}
function App() {
return (
<ThemeProvider>
<Header />
<main>
{/* ThemeButton is nested deep, yet reads the theme directly */}
<ThemeButton />
</main>
</ThemeProvider>
);
}
β The key win
ThemeButton can sit ten components deep and still reach the theme with one line. No intermediate component knows or cares that a theme exists.
The Custom-Hook Pattern
Calling useContext(ThemeContext) directly works, but production codebases almost always wrap it in a custom hook. This does two things: it hides the raw context object, and it lets you throw a helpful error if a component tries to use the context outside its Provider.
// theme-context.jsx
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext(null);
export 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>
);
}
// The custom hook: consume + guard in one place.
export function useTheme() {
const context = useContext(ThemeContext);
if (context === null) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
Now components import a clean, purpose-built hook and never touch useContext or the context object:
import { useTheme } from './theme-context';
function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return <button onClick={toggleTheme}>Current: {theme}</button>;
}
β οΈ Why the guard matters
If someone renders ThemeButton outside a ThemeProvider, the raw useContext silently returns the default value and you get a confusing bug far from its cause. The throw turns that into an immediate, named error that points straight at the mistake.
Context + useReducer for Complex State
Simple useState is fine for a theme flag. But when global state has many fields and many kinds of transitions β a cart, a notification queue, an auth session β useReducer gives you a single, predictable place where every change is defined. Pair it with Context and you get a lightweight, Redux-flavored store built entirely from React primitives.
Here is a shopping-cart store. Notice how all the update logic lives in one reducer, and the Provider exposes friendly helper functions so components never build raw action objects.
// cart-context.jsx
import { createContext, useContext, useReducer } from 'react';
const initialState = { items: [], itemCount: 0, total: 0 };
function cartReducer(state, action) {
switch (action.type) {
case 'ADD_ITEM': {
const item = action.payload;
const existing = state.items.find((i) => i.id === item.id);
const items = existing
? state.items.map((i) =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i
)
: [...state.items, { ...item, qty: 1 }];
return recalc(items);
}
case 'REMOVE_ITEM':
return recalc(state.items.filter((i) => i.id !== action.payload));
case 'CLEAR':
return initialState;
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// Derive totals in one place so state never drifts out of sync.
function recalc(items) {
return {
items,
itemCount: items.reduce((n, i) => n + i.qty, 0),
total: items.reduce((sum, i) => sum + i.price * i.qty, 0),
};
}
const CartContext = createContext(null);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
const value = {
...state,
addItem: (item) => dispatch({ type: 'ADD_ITEM', payload: item }),
removeItem: (id) => dispatch({ type: 'REMOVE_ITEM', payload: id }),
clear: () => dispatch({ type: 'CLEAR' }),
};
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
}
export function useCart() {
const context = useContext(CartContext);
if (context === null) {
throw new Error('useCart must be used within a CartProvider');
}
return context;
}
Consuming it is a joy β components describe what should happen, never how:
function AddToCartButton({ product }) {
const { addItem } = useCart();
return <button onClick={() => addItem(product)}>Add to cart</button>;
}
function CartBadge() {
const { itemCount } = useCart();
return <span className="badge">π {itemCount}</span>;
}
State after adding two of item #7 and one of item #3:
{
items: [
{ id: 7, name: "Mug", price: 12, qty: 2 },
{ id: 3, name: "Sticker", price: 3, qty: 1 }
],
itemCount: 3,
total: 27
}
The Re-render Trap
Context has one sharp edge you must respect: when a Provider's value changes, every component that reads that context re-renders β even components that only use a slice of the value that didn't change.
// β οΈ Problem: user and theme share one context.
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
// A NEW object every render β every consumer re-renders every time.
const value = { user, setUser, theme, setTheme };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
There are two independent fixes, and good apps use both.
Fix 1 β Split contexts by concern
If theme and user change for unrelated reasons, they should not live together. Give each its own context so a theme flip never re-renders user-only components.
// Separate providers β a theme change no longer touches user consumers.
<UserProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</UserProvider>
Fix 2 β Memoize the value
Even within one context, wrap the value object in useMemo so a new object is created only when its real data changes, not on every render.
import { useMemo, useState } from 'react';
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () =>
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
// Same object reference until `theme` actually changes.
const value = useMemo(() => ({ theme, toggleTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
π‘ Rule of thumb
Keep contexts small and focused, and memoize their values. If you find yourself fighting re-renders constantly, that is a signal your global state has outgrown Context β read on.
Context vs. State Libraries
Context is a transport mechanism for shared values, not a full state-management system. For richer needs, dedicated libraries add selectors (subscribe to just the slice you use), devtools, and less boilerplate. Here's how the popular options compare.
| Tool | Learning curve | Boilerplate | Fine-grained updates | Best for |
|---|---|---|---|---|
| Context + useReducer | Low (built in) | Moderate | No (needs manual work) | Theme, auth, smallβmedium apps |
| Zustand | Low | Very low | Yes (selectors) | Most apps wanting a simple store |
| Redux Toolkit | Moderate | Moderate | Yes (selectors) | Large teams, strict patterns, devtools |
| TanStack Query | Moderate | Low | Yes (per-query) | Server state: fetching & caching |
β οΈ A common mistake: server state in Context
Data fetched from an API (products, posts, the user's orders) is server state β it needs caching, refetching, and staleness handling. Context has none of that. Reach for a data-fetching library like TanStack Query for server data, and keep Context for genuine client state like theme and UI toggles.
The honest takeaway: reach for Context first because it's free and native. Graduate to a library when you feel real pain β constant re-render tuning, or state logic that's sprawling across many reducers.
Hands-on Exercise
ποΈ Build a Theme Context
Objective: Create a working light/dark theme context with a custom hook and a toggle button, in a fresh React 18 + Vite app.
Instructions:
- Scaffold an app:
npm create vite@latest theme-demo -- --template react, thencd theme-demo && npm install. - Create
src/theme-context.jsxthat exports aThemeProviderand auseThemehook (with the out-of-provider guard). - Wrap
<App />in<ThemeProvider>insidesrc/main.jsx. - In
App.jsx, calluseTheme(), apply the theme as a class on the root element, and render a button that callstoggleTheme. - Stretch goal: persist the choice by reading/writing
localStorageso the theme survives a page reload.
π‘ Hint
For persistence, initialize state lazily: useState(() => localStorage.getItem('theme') ?? 'light'), then use an useEffect that writes to localStorage whenever theme changes. You'll cover useEffect in depth in the next lesson.
β Solution
// src/theme-context.jsx
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(
() => localStorage.getItem('theme') ?? 'light'
);
useEffect(() => {
localStorage.setItem('theme', theme);
document.documentElement.dataset.theme = theme;
}, [theme]);
const value = useMemo(
() => ({
theme,
toggleTheme: () =>
setTheme((t) => (t === 'light' ? 'dark' : 'light')),
}),
[theme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (ctx === null) throw new Error('useTheme must be used within a ThemeProvider');
return ctx;
}
// src/App.jsx
import { useTheme } from './theme-context';
export default function App() {
const { theme, toggleTheme } = useTheme();
return (
<main>
<h1>Current theme: {theme}</h1>
<button onClick={toggleTheme}>Toggle theme</button>
</main>
);
}
π― Quick Quiz
Question 1: What problem is the Context API primarily designed to solve?
Question 2: Why do production codebases wrap useContext in a custom hook like useTheme?
Question 3: A single context holds both user and theme. Flipping the theme re-renders components that only read user. What's the cleanest fix?
Summary & Quiz
π Key Takeaways
- Context broadcasts a value to a whole subtree, curing prop drilling for truly global data.
- The rhythm is always create β provide β consume (
createContext,<Provider>,useContext). - Wrap consumption in a custom hook with an out-of-provider guard β the standard production pattern.
- Pair Context with
useReducerto manage complex, multi-action global state. - Watch the re-render trap: split contexts by concern and memoize the value; graduate to a library (Zustand, Redux Toolkit) or a data-fetching tool (TanStack Query) when Context strains.
π Further Reading
- React docs β Passing Data Deeply with Context
- React reference β useContext
- React docs β Scaling Up with Reducer and Context
π What's Next?
Our theme provider quietly used useEffect to sync with localStorage. Next up, Effects and Component Lifecycle takes useEffect apart in full β mounting, updating, cleanup, dependency arrays, and the mistakes that trip up nearly everyone.
π Nicely done!
You can now share state across an entire app without a single prop being drilled by hand.