Context API and State Management
useContext and useReducer for shared state, and when to reach for Redux or Zustand instead.
The prop drilling problem
Passing data from a parent to a deeply nested child works fine through ordinary props — until the component tree gets a few levels deep and a value needs to reach a component whose immediate parents don't otherwise care about it at all. Every component in between ends up re-declaring and forwarding a prop it never actually uses, purely so a descendant further down can read it:
function App() {
const theme = "dark";
return <Layout theme={theme} />;
}
function Layout({ theme }) {
return <Sidebar theme={theme} />; // Layout never uses theme itself
}
function Sidebar({ theme }) {
return <UserMenu theme={theme} />; // neither does Sidebar
}
function UserMenu({ theme }) {
return <div className={theme}>Menu</div>; // finally used here
}
This is prop drilling, and it's not wrong so much as it doesn't scale — adding a new piece of shared data means touching every intermediate component along the path, whether or not that component has anything to do with the data itself. React's Context API exists specifically to let a value skip straight from a provider to whatever descendant needs it, with no intermediate component involved.
createContext and useContext
Creating a context gives you a Provider component that makes a value available to its entire subtree, and a useContext hook that any descendant can call to read it directly:
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext("light"); // "light" is the default value if no Provider is above
function App() {
const [theme, setTheme] = useState("dark");
return (
<ThemeContext.Provider value={theme}>
<Layout />
<button onClick={() => setTheme(t => (t === "light" ? "dark" : "light"))}>
Toggle theme
</button>
</ThemeContext.Provider>
);
}
function Layout() {
return <Sidebar />; // no theme prop anywhere in between
}
function Sidebar() {
return <UserMenu />;
}
function UserMenu() {
const theme = useContext(ThemeContext); // reads straight from the nearest Provider above
return <div className={theme}>Menu</div>;
}
Layout and Sidebar no longer mention theme at all — only App (which owns the state) and UserMenu (which actually needs the value) know that a theme exists. The value passed to <ThemeContext.Provider value={...}> is what every useContext(ThemeContext) call anywhere beneath it will see, and it updates automatically — like any other state-driven re-render — whenever that value changes.
A component calling useContext without any Provider above it in the tree falls back to the default value passed to createContext ("light" above) — useful for a sensible default, and handy in tests that render a component in isolation.
Combining useContext with useReducer
Context solves how a value gets somewhere; it says nothing about how that value changes. For a single primitive like a theme string, a plain useState next to the provider is enough. For state with several related fields and various ways to update them — a shopping cart, a multi-step form, an authenticated user session — pairing Context with useReducer is a common and idiomatic combination: useReducer centralizes how the state changes into one reducer function, and Context distributes both the current state and a dispatch function to wherever they're needed, without threading either through props.
import { createContext, useContext, useReducer } from "react";
const CartContext = createContext(null);
function cartReducer(state, action) {
switch (action.type) {
case "added":
return { ...state, items: [...state.items, action.item] };
case "removed":
return { ...state, items: state.items.filter(i => i.id !== action.id) };
case "cleared":
return { ...state, items: [] };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return (
<CartContext.Provider value={{ state, dispatch }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error("useCart must be used inside a <CartProvider>");
}
return context;
}
function ProductRow({ product }) {
const { dispatch } = useCart();
return (
<li>
{product.name}
<button onClick={() => dispatch({ type: "added", item: product })}>
Add to cart
</button>
</li>
);
}
function CartSummary() {
const { state, dispatch } = useCart();
return (
<div>
<p>{state.items.length} item(s) in cart</p>
<button onClick={() => dispatch({ type: "cleared" })}>Clear cart</button>
</div>
);
}
function App() {
return (
<CartProvider>
<ProductRow product={{ id: 1, name: "Keyboard" }} />
<CartSummary />
</CartProvider>
);
}
A custom hook like useCart above (wrapping useContext and throwing a clear error if it's called outside its provider) is a small but genuinely worthwhile pattern — it turns "context used before it exists" from a confusing null reference bug into an immediate, readable error, and it keeps the raw CartContext object itself private to the module instead of imported and used directly everywhere.
When Context is enough, and when to reach for a library
Context plus useReducer covers a real, common slice of "shared app state" needs without adding a dependency — but it isn't a full state management solution, and it's worth being honest about where it stops being enough.
| Context + useReducer | Redux / Zustand (conceptually) | |
|---|---|---|
| Setup cost | None — built into React | An added dependency and some boilerplate |
| Re-render granularity | Every consumer of a context re-renders on any change to that context's value | Selector-based — a component subscribes only to the specific slice of state it reads |
| DevTools / time-travel debugging | Not built in | Available (Redux DevTools, similar tooling) |
| Middleware / persistence / undo | Roll your own | Often built in or available as an add-on |
| Good fit for | Low-to-medium frequency updates shared by a moderate subtree (theme, auth user, a cart) | App-wide state updated frequently, read by many unrelated components, or needing fine-grained performance control |
The practical dividing line is update frequency and re-render blast radius. Every component that calls useContext(SomeContext) re-renders whenever that context's value changes — there's no built-in way to subscribe to just one field inside it. That's a non-issue for a theme toggle or a rarely-changing auth user. It becomes a real performance problem for something like a chat app's live message list or a highly interactive dashboard, updated many times per second, consumed by many independent components — exactly where a library like Redux or Zustand earns its cost, since it lets each component subscribe only to the specific slice of state it actually reads, instead of re-rendering on every unrelated change to the same provider's value.
A reasonable default: start with Context (or even just lifting state up to a common parent) for genuinely shared state, and only introduce an external state library once a specific, measured re-render or organizational problem shows up that Context doesn't solve well.
Common mistakes
- Reaching for Context immediately for state that's really only shared by two or three components close together in the tree — lifting state up to their common parent and passing it down as props is simpler and doesn't need a provider at all.
- Putting fast-changing, high-frequency state (mouse position, every keystroke of a large form) into a single context that many unrelated components consume — every one of them re-renders on every update, since Context has no built-in field-level subscription.
- Forgetting to wrap the part of the tree that needs the value in the corresponding
Provider—useContextthen silently falls back tocreateContext's default value instead of throwing, which can be confusing to debug without a guard like theuseCarthook above. - Creating one giant context holding all of an app's state instead of several smaller, purpose-specific contexts (theme, auth, cart) — a single large context means an unrelated update to any one field re-renders every consumer of the whole thing.