Topic 15.2
When Context Isn't Enough
In one line
Every component that reads a context re-renders whenever the context value changes, even if it uses only one small part of it; splitting contexts helps, and an external store read through useSyncExternalStore with a selector fixes it properly.
Think of it like this
A school's loudspeaker. When the office announces "Class 3B, your bus is late", every classroom hears it and every class stops to listen, even though only 3B cares. Context works like that loudspeaker: when its value changes, every component that reads it stops and re-renders. A selector-based store is more like sending a note to one classroom: only the class whose news changed is disturbed.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Context consumer
- A component that reads a context with
useContextoruse. It re-renders whenever the context value changes. - Re-render cost
- The time React spends calling a component again and comparing its output. Usually small, but it adds up across many components.
- Splitting context
- Using several smaller contexts instead of one big one, so a change disturbs fewer components.
- External store
- State kept outside React in a plain object with get, set and subscribe functions.
- Selector
- A small function that picks the part of the state a component needs, like
(s) => s.items.length. - Subscribe
- Ask to be told when something changes. Returns a function to stop listening.
- Object.is
- The comparison React uses: same primitive value, or the very same object in memory.
Step by step
01Measure one big context
Put the user, the cart and the cart actions into one AppContext. Log each consumer's render, then click "Add pen" and "More pens". The first live example below does exactly this: every consumer renders on every click, 8 renders for 2 clicks.
02Split by change speed
Move the user into UserContext, the items into CartContext, and the actions into CartActionsContext with a value made once by useMemo. Now the user's name and the buttons don't render on cart clicks: 4 renders.
const actions = useMemo(() => ({
addPen: () => setItems((xs) => [...xs, pen]),
}), []); // setItems never changes, so actions never changes
return (
<UserContext value={user}>
<CartActionsContext value={actions}>
<CartContext value={items}>{children}</CartContext>
</CartActionsContext>
</UserContext>
);03Move the state outside React
Write a store with getState, setState and subscribe. setState builds a new state object and calls every listener. Nothing about it is React-specific: plain JavaScript can read and change it too.
function createStore(initial) {
let state = initial;
const listeners = new Set();
return {
getState: () => state,
setState: (update) => {
state = { ...state, ...update(state) };
listeners.forEach((l) => l());
},
subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); },
};
}04Read it with a selector
useSyncExternalStore subscribes the component and reads a snapshot. Pass a snapshot function that applies the selector, so the "snapshot" each component sees is only its slice. When the store notifies, React re-runs each component's snapshot function and skips the ones whose result is the same.
function useStore(selector) {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()));
}
const count = useStore((s) => s.items.length); // re-renders only when the count changes05Compare the numbers
Same app, same two clicks: one context 8 renders, split contexts 4, external store with selectors 3. In this tiny demo the difference costs nothing; with 200 rows that each read the store, it's the difference between a smooth and a janky page.
Try it yourself
- 1
Make the actions object unstable
In the split-contexts example, remove
useMemoand writeconst actions = { addPen: ..., morePens: ... };. Predict how many renders the two clicks cause now. Run it:Buttonsrenders again on every click (6 renders), because a newactionsobject is a changed context value. - 2
Select too much
In the external store example, change
CartCounttoconst items = useStore((s) => s.items);and showitems.length. Predict the render count. Run it:CartCountnow renders on More pens too, becausemorePensbuilds a newitemsarray. Select the smallest value you need. - 3
Change the store from outside React
Add
setTimeout(() => store.setState(() => ({ user: { name: "Ravi" } })), 400);at the end of the external store example. OnlyUserNamerenders. Any code (a WebSocket handler, a timer) can update an external store, and only the readers of that slice react.
Code & diagrams
The children are created once by the root, so they don't render because a parent rendered. They render only because they read `AppContext`, and its `value` is a new object every time the provider renders. All four consumers render on both clicks.
Preview
Console
Nothing logged yet.Expected console output
render UserName
render CartCount
render CartTotal
render Buttons
click Add pen
render UserName
render CartCount
render CartTotal
render Buttons
click More pens
render UserName
render CartCount
render CartTotal
render Buttons
renders for 2 clicks: 8`UserName` and `Buttons` no longer render on cart clicks. But `CartCount` still renders on **More pens**, although the number of lines didn't change: it reads the whole `items` array, and context can't select a part of it.
Preview
Console
Nothing logged yet.Expected console output
render UserName
render CartCount
render CartTotal
render Buttons
click Add pen
render CartCount
render CartTotal
click More pens
render CartCount
render CartTotal
renders for 2 clicks: 4No provider at all. On **More pens** the count selector still returns `1`, so `CartCount` is skipped; only `CartTotal`'s number changed. The actions are plain functions outside React, so `Buttons` never re-renders. This is the core of Zustand and react-redux.
Preview
Console
Nothing logged yet.Expected console output
render UserName
render CartCount
render CartTotal
render Buttons
click Add pen
render CartCount
render CartTotal
click More pens
render CartTotal
renders for 2 clicks: 3Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
A selector that returns a new object
In the external store example, write const { count } = useStore((s) => ({ count: s.items.length })); in CartCount.
Break #2
Mutating the store's state
Add a Lines component that reads const items = useStore((s) => s.items) and lists each item's qty. Then write morePens as () => { store.getState().items[0].qty++; store.setState(() => ({})); } and log the store and the screen after a click.
Myth vs fact
Myth
Context is slow, so never use it for state.
Fact
Context is a fine way to pass values that change rarely (theme, locale, current user). It becomes costly only when a frequently changing value is read by many or expensive components.
Myth
Wrapping consumers in memo stops context re-renders.
Fact
memo skips renders caused by a parent with unchanged props. A component that reads a context still re-renders when that context's value changes.
Myth
An external store means you need a library.
Fact
The pattern is about 15 lines on top of useSyncExternalStore. Libraries add conveniences: selector equality helpers, middleware, devtools, persistence and TypeScript types.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React has no built-in context selectors. A
useContextSelectorRFC and experiments have existed for years, but nothing has shipped as of React 19.2. Community libraries (such asuse-context-selector) fake it by putting a store inside context, which is the external store pattern again. - ▸
Updates that come from
useSyncExternalStoreare always synchronous: they can't be marked as transitions (startTransition, Topic 16.7). If a store update triggers a slow render, typing can lag. For slow derived views, keep the store update fast and pass the selected value throughuseDeferredValue. - ▸
A store inside context is the best of both: the store object is stable (so the context never changes), each test or server request can get its own store instance, and components still subscribe with selectors. react-redux's
<Provider store>and Zustand's "store in context" recipe work this way. Module-level singletons, like the live example'sstore, are shared by every request on a server, which leaks data between users in server rendering. - ▸
Selectors run on every store change for every subscribed component. Keep them cheap; for expensive derivations, memoise the selector (Reselect's
createSelector, Topic 15.4) so it recomputes only when its inputs change.
Remember this
- 1
Recall how context works (Topic 9.2): a provider (
<MyContext value={v}>) holds a value, and any component below it can read it withuseContext(MyContext)oruse(MyContext). When the provider'svaluechanges (compared withObject.is, so a new object counts as a change), React re-renders every consumer of that context. There is no way to say "re-render me only ifvalue.items.lengthchanged". Context has no selectors: you subscribe to the whole value or nothing. - 2
This becomes a problem when one context holds many things that change at different speeds. Put the user, the cart and the actions in one
AppContext, and every click on "add to cart" re-renders the user's name, the header and every button that only needs an action function.memo(Topic 16.3) doesn't save you: a component that reads a context re-renders when the context changes, whatever its props are. The React Compiler (Topic 16.5) doesn't change this either. - 3
Re-render cost is real but often small: re-rendering is calling your function and comparing its output, and React only touches the DOM where something differs (Topic 0.2). So a big context is fine for things that change rarely: the theme, the locale, the logged-in user, a feature-flag set. It hurts when the value changes often (on every keystroke, every cart change, every timer tick) and many or expensive components consume it. Measure with the React DevTools Profiler (Topic 16.6) before redesigning.
- 4
The first fix is splitting the context. Group values by how often they change and who reads them:
UserContext,CartContext, and a separateCartActionsContextwhose value is created once withuseMemo(setter functions fromuseStateanddispatchfromuseReducernever change, so an object holding only actions can stay the same forever). Components that only dispatch stop re-rendering. This is the "state context plus dispatch context" pattern from Topic 9.6. Its limit: a component readingCartContextstill re-renders on any cart change, even if it shows only the number of lines. - 5
The full fix is the external store pattern. Keep the state outside React in a small object with three functions:
getState()returns the current state,setState()replaces it with a new object and notifies listeners, andsubscribe(listener)adds a listener and returns an unsubscribe function. Components read it withuseSyncExternalStore(store.subscribe, () => selector(store.getState()))(Topic 10.3). A selector is a function that picks the small piece a component needs, like(s) => s.items.length. When the store changes, React calls every subscribed selector and re-renders only the components whose selected value changed (compared withObject.is). - 6
Two rules make selectors work. Return primitives or existing objects:
(s) => s.items.lengthor(s) => s.userare stable, but(s) => ({ count: s.items.length })returns a new object on every call, so React sees a change every time and loops (The result of getSnapshot should be cached to avoid an infinite loop, thenMaximum update depth exceeded). And update immutably: the store must create new objects for what changed ({ ...state, items: newItems }), or the selectors can't tell anything changed. Zustand (Topic 15.3) and react-redux (Topic 15.4) are this exact pattern with these rules handled for you.
Explain it without notes
Why does a context change re-render components that don't use the changed part?
How would you reduce re-renders from a large app context without adding a library?
Explain the external store pattern and how selectors avoid unnecessary renders.
When is context still the right tool?
Practice
Using the tiny createStore from this topic, build a counter store with +1 and +2 buttons and two components: Count shows the number, Parity shows "even" or "odd". Log renders and confirm that Parity skips its render when you click +2.
Rewrite an AppContext value { theme, setTheme, items, setItems } into two contexts so that components that only call setItems never re-render.
Trade-offs
- ↔
Splitting contexts keeps everything inside React (no new concepts) but multiplies providers and still re-renders every reader of a changed context. An external store gives fine-grained updates but moves state out of React's tree, so you must handle per-request instances for server rendering and tests.
- ↔
Selectors make components cheaper to re-render but add a function per read and a new failure mode (returning new objects). Libraries reduce, but don't remove, that sharp edge.
Done when you can
I can explain why every context consumer re-renders when the value changes.
I can split a context by change speed and keep the actions context stable.
I can write a small external store with getState, setState and subscribe.
I can read a store slice with
useSyncExternalStoreand a selector.I know selectors must return primitives or existing objects.
I measure re-renders before choosing a bigger tool.