Module 2: Context For Shared Client State
The re-render cost of Context
Overview
The previous lessons told you what goes in Context and repeated a condition: "changes little". This lesson shows you why that condition is non-negotiable, by measuring it. Context has a cost everyone learns late: when the Provider's value changes, the whole subtree that consumes that Context re-renders —not only the components that use the part that changed, but all that consume the Context—. You're going to see it counted: a "fat" Context with { theme, user, cart } re-renders 6 consumers when only the cart changes, even though four of them don't care about the cart. And you'll see the two cures: splitting the Context (split) into ThemeContext / UserContext / CartContext, which drops that same change to 2 re-renders; and memoizing the value so as not to create a new object on each render that triggers spurious re-renders (without the data having changed). With these numbers you understand once and for all why state that changes often doesn't go in Context, and why it's sometimes worth having several Contexts instead of one.
Connection with the module. It's the module's cost, measured. Lesson 4 gave the criterion ("changes little and read by many"); this one proves the most important half of that criterion —why "changes little"— with numbers, and gives the techniques to bound the cost when the usage is legitimate. It justifies at the root why the cart doesn't go in Context (teaser of module 3) and why Mercado uses separate contexts for the theme and the user (lesson 7). The boundary: the complete study of React's re-render —memo, how it propagates, the reconciler— is the performance guide's; here we measure just enough to decide well.
An analogy: the building's loudspeaker
Imagine an office building with a central loudspeaker heard on all floors at once. It's convenient for what truly matters to everyone: "fire drill at 3 pm" is announced once and the whole building hears it. But notice the problem if you use it for anything: if over that same loudspeaker you announce "a package arrived for desk 4B", all ten floors hear the interruption, even though only one person cares. The loudspeaker can't filter: everything that sounds, sounds everywhere. And if you also used the loudspeaker for something that changes constantly —"it's 10:00:01… it's 10:00:02…"—, it would be torture: constant interruptions for the whole building over something that concerns almost no one.
That loudspeaker is a Context Provider, and "hearing the announcement" is re-rendering. When you change the Provider's value, the announcement sounds throughout the whole consuming subtree: everyone who tunes into that Context re-renders, whether or not they care about the part that changed. For a rare announcement of general interest (the theme, the user) it's fine —it sounds little, and everyone's affected—. For something that changes often (the cart) it's the torture of the talking clock. The solution? Separate loudspeakers by topic: one for emergencies (which the whole building should indeed hear), another only for the shipping floor, another only for accounting. That way, "a package arrived" sounds only on shipping, not on all ten floors. That's splitting the Context: a separate ThemeContext, a UserContext, a CartContext, so each announcement sounds only where it matters. This lesson measures exactly how much noise that separation saves.
Worked example: count the re-renders (fat vs split)
We're going to measure the cost with numbers. First, how the problem looks in real React. A fat Context puts all the global state in a single Provider, and any change —even the cart's— publishes a new value that re-renders all its consumers:
// FAT Context: a single Provider with everything. Changing the cart re-renders EVERYTHING.
const AppContext = createContext(null);
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(guest);
const [cart, setCart] = useState([]); // <- changes with EACH "Add to cart"
// new value every time any of the three changes:
return (
<AppContext.Provider value={{ theme, user, cart, setTheme, setUser, setCart }}>
<Layout />
</AppContext.Provider>
);
}
// ThemeToggle, Header, ProductCard, Footer only use theme or user...
// ...but re-render anyway when the cart changes, because they consume the SAME Context.
And the split version, with a Context per topic, where changing the cart only touches the cart's consumers:
// SPLIT Context: one Provider per data. Changing the cart only touches the cart's.
const ThemeContext = createContext('light');
const UserContext = createContext(guest);
const CartContext = createContext([]);
function App() {
const [theme] = useState('light');
const [user] = useState(guest);
const [cart] = useState([]);
return (
<ThemeContext.Provider value={theme}>
<UserContext.Provider value={user}>
<CartContext.Provider value={cart}>
<Layout />
</CartContext.Provider>
</UserContext.Provider>
</ThemeContext.Provider>
);
}
Let's execute the two cases, recording which consumers re-render when the user adds an item (only the cart changes). We model each Context with its list of consumers, and "changing the value" as triggering a re-render to everyone who consumes it:
function makeContext(name) { return { name, consumers: [] }; }
function consume(context, label) { context.consumers.push(label); }
// "changing the value" -> re-renders ALL who consume that context.
function setValue(context, note) {
console.log(` value of <${context.name}> changes (${note}):`);
context.consumers.forEach((c) => console.log(` re-render -> ${c}`));
console.log(` consumers re-rendered: ${context.consumers.length}`);
return context.consumers.length;
}
// --- Case A: ONE fat Context with {theme, user, cart} ---
console.log('=== Case A: a single AppContext = { theme, user, cart } ===\n');
const AppContext = makeContext('AppContext');
['ThemeToggle (theme)', 'Header (user)', 'ProductCard (theme)',
'CartBadge (cart)', 'CartView (cart)', 'Footer (theme)'].forEach((c) => consume(AppContext, c));
const a = setValue(AppContext, 'the user adds an item: only the cart changes');
console.log(` -> even though only the cart changed, ${a} consumers re-rendered (all of them).`);
// --- Case B: SPLIT into three contexts ---
console.log('\n=== Case B: split into ThemeContext / UserContext / CartContext ===\n');
const ThemeContext = makeContext('ThemeContext');
const UserContext = makeContext('UserContext');
const CartContext = makeContext('CartContext');
['ThemeToggle', 'ProductCard', 'Footer'].forEach((c) => consume(ThemeContext, c));
['Header'].forEach((c) => consume(UserContext, c));
['CartBadge', 'CartView'].forEach((c) => consume(CartContext, c));
const b = setValue(CartContext, 'the user adds an item: only the cart changes');
console.log(` -> now ${b} consumers re-rendered (only the cart's).`);
console.log(`\n Split: from ${a} re-renders to ${b}. Each Context touches only its own.`);
// --- The other cost: a NEW object value on each render ---
console.log('\n=== new object value vs stable value (identity) ===\n');
const same = (x, y) => x === y;
const v1 = { theme: 'dark' };
const v2 = { theme: 'dark' }; // same content, NEW object
console.log(` value={{theme:'dark'}} recreated on each render: same identity? ${same(v1, v2)}`);
console.log(' -> false: Context sees a "different" value and re-renders even though theme did not change.');
const stable = { theme: 'dark' };
console.log(` memoized value (same reference): same identity? ${same(stable, stable)}`);
console.log(' -> true: no spurious changes. That\'s why the value is wrapped in useMemo.');
What to expect. When you run the file with Node, the output is exactly this:
=== Case A: a single AppContext = { theme, user, cart } ===
value of <AppContext> changes (the user adds an item: only the cart changes):
re-render -> ThemeToggle (theme)
re-render -> Header (user)
re-render -> ProductCard (theme)
re-render -> CartBadge (cart)
re-render -> CartView (cart)
re-render -> Footer (theme)
consumers re-rendered: 6
-> even though only the cart changed, 6 consumers re-rendered (all of them).
=== Case B: split into ThemeContext / UserContext / CartContext ===
value of <CartContext> changes (the user adds an item: only the cart changes):
re-render -> CartBadge
re-render -> CartView
consumers re-rendered: 2
-> now 2 consumers re-rendered (only the cart's).
Split: from 6 re-renders to 2. Each Context touches only its own.
=== new object value vs stable value (identity) ===
value={{theme:'dark'}} recreated on each render: same identity? false
-> false: Context sees a "different" value and re-renders even though theme did not change.
memoized value (same reference): same identity? true
-> true: no spurious changes. That's why the value is wrapped in useMemo.
Read the three blocks, because they're Context's whole cost and its two cures.
Case A is the single loudspeaker. The AppContext has six consumers; four of them (ThemeToggle, ProductCard, Footer, and the Header) use theme or user, not the cart. But when the user adds an item and only the cart changes, the Provider's value ({ theme, user, cart }) is a new object, so the "announcement" sounds throughout the subtree: all 6 re-render, including the four that don't care about the cart. That's the waste: four useless re-renders for a change that didn't concern them. And remember that the cart changes often —each click—: six re-renders on each "Add to cart" is exactly the talking-clock torture.
Case B is the split loudspeaker. Now there are three separate Contexts: the ThemeContext (consumed by ThemeToggle, ProductCard, Footer), the UserContext (Header) and the CartContext (CartBadge, CartView). When the cart changes, the announcement sounds only on the CartContext: 2 consumers re-render —the ones that truly use the cart—, and the theme's and the user's don't even notice. The result, measured: from 6 re-renders to 2. Splitting the Context didn't change what the app does; it changed how much each change re-renders. Each data on its own channel, and each change touches only its readers.
The third block teaches the subtlest cost, the one that happens even when the data doesn't change. Notice: { theme: 'dark' } and { theme: 'dark' } have the same content, but same(v1, v2) gives false —they're two different objects, with different identity—. Context compares the value by reference identity, not by content: if on each render of the Provider you write value={{ theme }}, you create a new object every time, Context sees it "different" from the previous one, and re-renders all consumers even though the theme is the same. That's a spurious re-render: work for nothing. The cure is shown by the last line: a value with stable identity (same(stable, stable) gives true) doesn't trigger a re-render. In React that's achieved by wrapping the value in useMemo (or passing a direct primitive value, like value={theme}, which is compared by value). Practical rule: if the value is an object, memoize it.
The moral brings the three pieces together: Context re-renders all its consuming subtree when its value changes (that's why state that changes often doesn't go here); splitting the Context reduces the reach of each change (6 → 2); and memoizing the value avoids spurious changes by identity. The three are the reason, measured, behind "Context is for what changes little".
Deep dive: why it happens, and how to bound it
Why it re-renders the whole subtree. When a Provider's value changes (by identity), React marks all the components that do useContext of that Context to re-render. There's no mechanism, in pure Context, to say "only re-render me if the part I use changed": the consumer is subscribed to the whole value. That's why the size of what you put in a Context matters: the more you put together, the more disparate consumers hang off the same change. (A store like Zustand, from module 3, solves exactly this with selectors: you subscribe to state.cart, not state, and only re-render if that slice changed. Context has no native selectors.)
Cure 1: split the Context (split by concern). One Context per concern: ThemeContext, UserContext, LocaleContext. That way, changing the theme doesn't re-render those that only read the user. It's the technique you measured (6 → 2) and the one Mercado uses in lesson 7. It costs a bit more scaffolding (several nested Providers), but keeps each change bounded to its readers.
Cure 1b: separate the value from the functions that change it. A common pattern is to split even more: one Context for the state (theme) and another for the actions (setTheme/dispatch). Why? Because actions never change identity (they're stable), so components that only dispatch (a button that calls dispatch) can read the "actions Context" and not re-render when the state changes. It's a refinement of the split; useful when many components only write and few read.
Cure 2: memoize the value. If your value is an object ({ theme, setTheme }), wrap it in useMemo so it keeps its identity between renders while its parts don't change:
function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Without useMemo, {theme, setTheme} would be a NEW object on each render of the Provider
// -> spurious re-render of all consumers. With useMemo, stable identity.
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
With value={theme} (a primitive) you don't need useMemo, because Context compares it by value. The useMemo is for when the value is an object you build in the render.
Where this ends and the store begins. These cures make Context acceptable for its band (changes little, read by many). But if the data changes often —the cart—, neither splitting nor memoizing saves it: each real change still re-renders all the consumers of that Context, and there are many changes. There the right tool is a store with selectors (module 3), not Context. The rule doesn't change: Context for the stable; store for what changes often.
flowchart TD
subgraph gordo["Fat Context: cart changes -> 6 re-renders"]
AC["AppContext {theme,user,cart}"] --> R1[ThemeToggle] & R2[Header] & R3[ProductCard] & R4[CartBadge] & R5[CartView] & R6[Footer]
end
subgraph split["Split: cart changes -> 2 re-renders"]
TC[ThemeContext] --> t1[ThemeToggle] & t2[ProductCard] & t3[Footer]
UC[UserContext] --> u1[Header]
CC["CartContext (changes)"] --> c1[CartBadge] & c2[CartView]
end
Common mistakes
A single fat Context for all the global state. What happens: you put { theme, user, cart, locale, ... } in an AppContext, and any change of any one re-renders all the consumers of everything. Why it happens: it seems tidy to have "the app's state" in one place. How to detect it: changing one thing (the theme) re-renders components that only use another (the cart). How to fix it: split the Context by concern —one per data that changes on its own—, so each change touches only its readers (6 → 2 in the example). Order isn't in putting it all together, but in separating it well.
Creating the object value inline on each render. What happens: you write <Ctx.Provider value={{ theme, setTheme }}> and, unintentionally, create a new object on each render of the Provider, triggering spurious re-renders of all consumers even though the data didn't change. Why it happens: it's the natural way to write it, and the object "looks" the same. How to detect it: the consumers re-render when the Provider re-renders for any reason, without their value having changed. How to fix it: memoize the value with useMemo(() => ({ theme, setTheme }), [theme]), or pass a primitive (value={theme}). Stable identity avoids the useless work.
Using Context for what changes often and "fixing it" with memo. What happens: you put the cart (or an input) in Context, notice the slowness, and think memoizing or splitting will save it. But the problem isn't the spurious identity: it's that the data changes for real many times, and each real change re-renders all its consumers. Why it happens: the spurious cost (which memo cures) is confused with the real cost (which it doesn't). How to detect it: the value changes dozens of times per second and memo doesn't help because the changes are legitimate. How to fix it: that doesn't go in Context; it goes in a store with selectors (module 3). Memo and split bound Context's cost; they don't turn it into a high-frequency state manager.
Exercises
Exercise 1 — Count the re-renders. A fat AppContext has these consumers: Navbar (uses user), Avatar (uses user), ThemeSwitch (uses theme), PriceTag x3 (use theme), CartIcon (uses cart). (a) If the theme changes, how many consumers re-render? (b) If you split into UserContext / ThemeContext / CartContext, how many re-render on changing the theme? (c) How many were saved?
See solution
Total consumers: Navbar, Avatar (user); ThemeSwitch, PriceTag×3 (theme); CartIcon (cart) = 7.
- (a) Fat Context, theme changes → 7 re-renders. All the consumers of the
AppContextre-render, even thoughNavbar,AvatarandCartIcondon't use the theme. - (b) Split, theme changes → 4 re-renders. Only the
ThemeContext's:ThemeSwitch+PriceTag×3 = 4. - (c) 3 useless re-renders were saved (
Navbar,Avatar,CartIcon), which with the fat Context re-rendered for a change that didn't concern them.
Splitting bounded the announcement to those who really listen to that channel.
Exercise 2 — Spurious or real? For each situation, say whether the re-render is spurious (by identity, cured with memo) or real (the data changed, memo doesn't help): (a) the Provider re-renders for another reason and its value={{theme}} is recreated, with theme the same; (b) the user turns on dark mode and theme goes from 'light' to 'dark'; (c) the user types in an input whose text is in the Context's value.
See solution
- (a) Spurious. The
themedidn't change, but the{theme}object is new (different identity) → useless re-render. Memoizing thevaluecures it. - (b) Real. The
themereally changed ('light' → 'dark'); the theme's consumers must re-render to show it. It isn't a problem; it's Context doing its job. (And since the theme changes little, this real re-render happens rarely: perfect for Context.) - (c) Real and frequent. The text changes with each keystroke; each change is legitimate and re-renders all the Context's consumers, many times per second. Memo doesn't help (the changes are real). Conclusion: that doesn't go in Context —it's local, or a store—.
The distinction: memo/split cure the spurious cost (identity) and bound the real one (split); but a piece of data that changes often has too many real costs for Context.
Exercise 3 — Design Mercado's Contexts. You're going to set up Mercado's global client state with Context. (a) How many Contexts would you create and which? (b) Why not a single AppContext with everything? (c) The cart, would you put it in a CartContext? Justify with the re-render cost.
See solution
- (a) Two separate Contexts:
ThemeContext(theme) andUserContext(user). If there were a language, aLocaleContexttoo. One per concern (split). - (b) Because a fat
AppContextwould re-render all the consumers when any of the data changes: turning on dark mode would re-render those that only read the user, and vice versa. Splitting keeps each change bounded to its readers (what you measured, 6 → 2). - (c) I would not put it in Context, not even in its own
CartContext. The cart changes often (each "Add to cart"), and although a separateCartContextwould bound the re-render to the cart's consumers, they'd still be many real changes, each re-renderingCartBadge,Cart, checkout… The cart goes in a store with selectors (module 3), where theCartBadgesubscribes only to the count. Split and memo don't save a high-frequency data.
This design is exactly lesson 7's.
Summary and next step
In this lesson you measured Context's cost, which is the underlying reason for its criterion. When a Provider's value changes, all its consuming subtree re-renders: a fat Context with { theme, user, cart } triggered 6 re-renders on changing only the cart, four of them useless. Splitting the Context into ThemeContext / UserContext / CartContext dropped that change to 2 re-renders —each announcement sounds only where it matters—. And memoizing the value avoided the spurious cost: a new object on each render (identity false) re-renders even though the data didn't change; a stable value (true) doesn't. You anchored it with the building's loudspeaker: a central one for rare emergencies (fine), but separate loudspeakers per floor for what doesn't concern everyone, and never the talking clock over the general loudspeaker.
Before moving on you should be able to: explain why Context re-renders its whole subtree on changing the value; apply the two cures (split by concern, memoize the object value); distinguish a spurious re-render (identity, memo cures it) from a real one (the data changed); and justify with the cost why the cart doesn't go in Context.
Lesson 6 clarifies an underlying confusion that already surfaced: Context is not a state manager. Notice that in the examples, the state (theme, user, cart) always lived in a useState above, in the Provider —Context only distributed it—. You're going to see, executed, the canonical Context + reducer pattern: the themeReducer (a pure function) has the state and the logic, the Provider manages it with useReducer and publishes { theme, dispatch }, and the consumers only read. Context is the cable; the reducer is the studio that produces the signal.
Resources
- React, "useContext: Optimizing re-renders when passing objects and functions" — react.dev/reference/react/useContext#optimizing-re-renders-when-passing-objects-and-functions. The official section on memoizing the
valuewithuseMemo/useCallbackto avoid spurious re-renders. In English. - React, "useMemo" — react.dev/reference/react/useMemo. The Hook that stabilizes the identity of a Provider's object
value. In English. - TkDodo, "Zustand and React Context" — tkdodo.eu/blog/zustand-and-react-context. Why Context re-renders its whole subtree and how a store with selectors avoids that cost; the bridge to module 3. In English.
- Kent C. Dodds, "How to optimize your context value" — kentcdodds.com/blog/how-to-optimize-your-context-value. Splitting the Context and separating state from actions to bound the re-renders; this lesson's cures explained in depth. In English.