Runnable lesson · press Try it on the examples to edit and run them here.
You learned the basics of Context in useContext. This lesson shows the structure real apps use: a provider component that owns the state, a custom hook to read it, and habits that avoid performance problems.
The pattern
- Create the context in its own file.
- Write a Provider component that holds the state and provides it.
- Write a custom hook that reads the context and complains if used outside the provider.
const CartContext = React.createContext(null);
function CartProvider({ children }) {
const [items, setItems] = React.useState([]);
const add = React.useCallback((item) => setItems((list) => [...list, item]), []);
const clear = React.useCallback(() => setItems([]), []);
const total = items.reduce((sum, i) => sum + i.price, 0);
// Memoize the value so consumers do not re-render when the provider re-renders for other reasons
const value = React.useMemo(() => ({ items, total, add, clear }), [items, total, add, clear]);
return <CartContext value={value}>{children}</CartContext>;
}
// The only way components read the cart
function useCart() {
const context = React.useContext(CartContext);
if (context === null) {
throw new Error("useCart must be used inside a <CartProvider>");
}
return context;
}
function ProductButtons() {
const { add } = useCart();
return (
<div>
<button onClick={() => add({ name: "Pen", price: 2.5 })}>Add Pen</button>{" "}
<button onClick={() => add({ name: "Book", price: 15 })}>Add Book</button>
</div>
);
}
function CartSummary() {
const { items, total, clear } = useCart();
if (items.length === 0) return <p>Cart is empty</p>;
return (
<div>
<p>{items.length} items, ${total.toFixed(2)}</p>
<button onClick={clear}>Clear cart</button>
</div>
);
}
function App() {
return (
<CartProvider>
<ProductButtons />
<CartSummary />
</CartProvider>
);
}
Why this shape works:
- The state and its update rules live in one place, the provider, so components just call
add()orclear(). useCarthides the context object. Components do not importCartContext; they calluseCart(). You can change the internals later without touching them.- The guard turns a confusing
undefined is not iterableerror into a clear message about the real mistake. Appjust wraps the part of the tree that needs the cart.
Why useMemo on the value?
When CartProvider re-renders (for any reason, such as its parent re-rendering), a new value object is created. Context compares the value with Object.is, so a new object means every consumer re-renders, even if nothing inside changed. useMemo returns the same object until its dependencies change. In a small app this does not matter; in a large one it can. The React Compiler can add this memoization for you (React Compiler).
Split contexts by what changes together
One giant context with everything makes every consumer re-render for every change. Split by update frequency or by purpose:
<ThemeProvider> // rarely changes
<AuthProvider> // changes at login and logout
<CartProvider> // changes often
<App />
</CartProvider>
</AuthProvider>
</ThemeProvider>
Another useful split is state vs dispatch: components that only trigger actions (add) need not re-render when the data changes. Put the stable functions in a second context.
const CountContext = React.createContext(0);
const ActionsContext = React.createContext(null);
// memo: skip re-rendering when the parent re-renders with the same (no) props
const CountButton = React.memo(function CountButton() {
const actions = React.useContext(ActionsContext); // stable: never changes
const renders = React.useRef(0);
renders.current++;
return (
<div>
<button onClick={actions.inc}>Count up</button>
<p>Renders of button: {renders.current}</p>
</div>
);
});
function CountText() {
const count = React.useContext(CountContext);
return <p>Count: {count}</p>;
}
function App() {
const [count, setCount] = React.useState(0);
const actions = React.useMemo(() => ({ inc: () => setCount((c) => c + 1) }), []);
return (
<ActionsContext value={actions}>
<CountContext value={count}>
<CountButton />
<CountText />
</CountContext>
</ActionsContext>
);
}
CountText re-renders on every click because it reads the changing CountContext. CountButton reads only the stable ActionsContext and is wrapped in memo, so it does not re-render when the count changes: its own render counter stays at 1. Both parts are needed: splitting the contexts stops context-caused re-renders, and memo stops re-renders that come from the parent re-rendering (Performance Optimization).
Pitfalls
- Using context for fast-changing values (mouse position, form text). Every consumer re-renders each time.
- A provider too high in the tree, causing wide re-renders. Place it as low as practical.
- Forgetting the provider. The default value silently applies. The guard in
useCartprevents it. - Context as a global store. It does not give selectors or middleware. For large shared client state, a store library is a better fit.
- Creating the context inside a component. Always define
createContextat module level.
With a reducer
For state with many update types, combine context with useReducer (Reducers and useReducer): the provider holds [state, dispatch], and consumers dispatch actions. This pattern is a lightweight alternative to a store library.
Key points
- Structure Context as a Provider component plus a custom hook with a guard.
- Memoize the provided value and split contexts by what changes together.
- Keep context for broadly needed, slowly changing data.