What you'll learn: By the end of this lesson you will be able to explain what React context is for, say exactly when its readers re-render, spot the value shapes that cause needless re-renders, choose between a stable value, split contexts, an outer reader and an external store with a selector, and verify your choice by counting renders in a test.
Think of a loudspeaker in an open-plan office. One announcement reaches everyone who is within earshot, whether or not it concerns them, and nobody has to be handed a note. That is convenient for "the fire drill starts at noon" and wasteful for "Priya, your parcel has arrived", spoken to a hundred people who all stop and listen.
React context is that loudspeaker. A provider announces a value, and any component below it can read the value without props passed through every level. When the announcement changes, every reader is told. This lesson is about what that costs and how to make announcements reach fewer people.
The running example is an analytics dashboard: a header showing the user's name, a theme toggle, a notifications panel and a data table. User and theme feel like context material. The lab uses React 19.3.1; react.dev's versions page listed 19.3 as the latest release when this was written. This lesson builds on the previous one in the course, Memoization and Controlling Re-renders in React (plain text here, because its page is published separately).
The core idea
- A context reader renders when the provider's value changes. React compares the previous and next
valuewithObject.is. If they differ, every component that reads that context re-renders. memodoes not shield a reader. React'suseContextreference says "skipping re-renders withmemodoes not prevent the children receiving fresh context values."- Cost is changes times readers. You can lower the number of changes (keep the value stable), or lower the number of readers affected by each change (split, select).
How context works
You create a context with createContext(defaultValue), outside any component. In React 19 you provide a value by rendering the context itself, <ThemeContext value={theme}>; older versions use <ThemeContext.Provider>. A component calls useContext(ThemeContext) and receives the value from the closest provider above it. The default value is only a last-resort fallback "static and never changes": it is used only when no matching provider is above the component at all, so a provider whose value is undefined gives readers undefined, not the default. The lab has a test for that case.
React re-renders readers automatically "starting from the provider that receives a different value". The change check is the same Object.is used for props and hook dependencies, which is why the previous lesson's rules about new objects carry over.
Figure 1. A provider and its readers
How to read this: the user-name reader (a warning colour) only needed the user, but the value that carries the theme also carries the user, so it is told about every theme change.
Illustration 1: the minimal example, a value that churns
The lab's AppProvider holds a theme state and an unrelated counter, and provides one object with user, theme and a toggle. It takes its children from its parent. Its two variants differ in one line: whether the value object is rebuilt on every render of the provider, or cached with useMemo. React's reference describes the fix: wrap the object in useMemo and its functions in useCallback, "a performance optimization", so readers re-render only when the data changes.
The lab clicks the unrelated button twice, which re-renders the provider but changes no data:
| Provider value | Readers re-rendered after two unrelated provider renders |
|---|---|
| Fresh object every render | All three readers, once per click (3 renders each including mount) |
useMemo on [theme, toggleTheme] |
None (1 render each, the mount) |
The test for the fresh value also shows a non-reader, a footer, rendered once. It was passed in as children, so the provider's own re-render handed React the same element object. The pages read for this lesson do not state that as a guarantee; it is behaviour observed in the lab with React 19.3.1, so rely on it only with a test beside it.
Illustration 2: the realistic example, fan-out from one fat context
Now the dashboard. One context carries { user, theme, toggleTheme }. The header's user-name component reads the whole object but uses only user. When someone toggles the theme, the lab counts every reader:
| Component | Reads | Renders after one theme toggle |
|---|---|---|
ThemeToggle |
theme, toggle | 2 (needed) |
UserName |
whole context, uses user |
2 (not needed) |
MemoUserName (wrapped in memo) |
whole context, uses user |
2 (memo did not help) |
Footer |
nothing | 1 (not told) |
The value was already memoized, so the extra renders are not churn. They are fan-out: the value legitimately changed and every reader was told, including those that only use an unchanged part.
Figure 2. What happens when a context value changes
How to read this: the readers are told because the whole value changed, not because the part they use changed. React has no built-in way for a reader to say "only wake me if user changes".
Four ways to reduce the fan-out
Figure 3. Choosing a technique
more than expected"] --> B{"Is the value rebuilt
without data changing?"} B -->|"yes"| C["Memoize the value
(useMemo, useCallback)"] B -->|"no"| D{"Do readers use only
part of the value?"} D -->|"two concerns"| E["Split into contexts
(state and dispatch)"] D -->|"one reader, small slice"| F["Read in an outer component,
pass a prop to a memo child"] D -->|"many readers, fast changes"| G["External store
with a selector"]
How to read this: go top to bottom and stop at the first technique that fixes the measured problem. Most dashboards need only the first two.
1. Stabilise the value. Covered in illustration 1. It removes re-renders that carry no new information, and costs one dependency list.
2. Split by concern. React's guide on scaling up with reducer and context keeps two contexts, one for the current tasks and one for the dispatch function, with custom hooks over each. Its stated benefit is modest: it lets you "later split these contexts further". The render benefit follows from the rules above. A component that only dispatches reads only the dispatch context, whose value, the dispatch function, does not change. In the lab, after two "Add task" clicks the list rendered 3 times and the dispatch-only button once. With one combined, memoized context holding both, the button rendered 3 times as well.
3. Read in an outer component and pass a prop. React's memo reference suggests this: read what you need from the context in the outer component and pass it to a memoized child as a prop. In the lab, GreetingShell reads { user, theme } and passes user.name to a memo child. After a theme toggle the shell rendered twice and the child once, because the string did not change.
4. Move fast-changing data to an external store with a selector. React has no selector for context, but useSyncExternalStore lets a component subscribe to a store and re-render only if the value it returns changes by Object.is. Its reference requires getSnapshot to return an immutable value and "the same value" while the store has not changed. The lab's 20-line store wraps that in useStoreSelector(store, selector):
export function useStoreSelector<S, T>(store: Store<S>, selector: (state: S) => T): T {
return useSyncExternalStore(store.subscribe, () => selector(store.getState()));
}
With one store holding a user, a cart count and a theme, an update to the cart count re-rendered only the cart cell. An update to the theme re-rendered none of the three cells. An update that replaced the user object with an equal one re-rendered the cell that selected the object but not the cell that selected the name string. React's reference also says built-in state is preferred when possible and that this API is "mostly useful" for integrating non-React stores, so reach for it when measurements justify the extra moving part.
Illustration 3: a failure and an edge case
A selector that builds an object. useStoreSelector(store, (s) => ({ name: s.user.name })) returns a new object on every call, so the snapshot never looks stable. React's reference describes the consequence for always-different snapshots as an infinite loop and the error "The result of getSnapshot should be cached". The lab's test asserts that rendering such a component throws. The fix is to select a primitive or an existing reference.
A provider with the wrong default. A reader without any provider silently receives the default. For a context where a missing provider is a bug, the lab's hook throws a clear error instead of returning a default. That turns a quiet wrong render into a loud early failure.
Trade-offs
| Technique | Cuts | Costs | Use when |
|---|---|---|---|
| Memoized value | Re-renders with no data change | A dependency list to keep correct | Always, for provider values that are objects |
| Split contexts | Readers that use only one concern | More providers, more hooks, a bigger tree | Clear concerns with different change rates (state versus dispatch) |
| Outer reader plus prop | A memo child's render | An extra component; the outer one still renders | One expensive child needs a small slice |
| External store with selector | Readers of unchanged slices | A store, subscription code, immutable updates | Frequent updates with many readers |
| Nothing | Code | Whole-subtree re-renders on every change | The value changes rarely and the tree is small |
Build it: count the fan-out
Goal. Predict, then verify, which components render when a context value changes, and watch each technique reduce the count.
Setup. The lab is examples/frontend/labs/react-context-render-cost. It uses React 19.3.1 and Testing Library under jsdom, a JavaScript model of the DOM that is not a browser: no layout, no painting, no timing. The lab measures no time at all.
cd examples/frontend
pnpm install
cd labs/react-context-render-cost
pnpm test # 16 tests
pnpm typecheck
Steps.
- Read
src/contexts.tsxandsrc/store.tsxbesidetest/context.test.tsx. For each test, write down your predicted counts first. - Run the tests. All 16 passed in the run made while writing this lesson (React 19.3.1, vitest 5.1.0, jsdom 31.0.0).
- Compare the "split" and "combined" task tests and explain the difference using the
Object.isrule.
Break it. In AppProvider, add {} to the dependency list of the memoized value: [theme, toggleTheme, {}]. The "memoized value re-renders no reader" test fails (confirmed by running it), because a fresh {} is a new dependency every render. Revert it.
Stretch. Give the store a selector variant that accepts an equality function, and use it to make the unstable selector work. Decide whether that is better than fixing the selector. The lab has no demo script or golden output: the test assertions are the output, because Node cannot run a .tsx file directly.
In practice: a dashboard that re-renders on every notification
This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.
The situation
An analytics dashboard keeps user, theme, filters and a live notification list in one AppContext. A push connection adds a notification every few seconds (illustrative). Users report the data table stutters while they scroll, and a profile on a throttled device shows the whole page re-rendering on each notification.
Starting architecture
Figure 4. One context, every component a reader
How to read this: only the panel needs the notification list, but the value that carries it also carries the filters and the user, so the table re-renders for every message.
Options considered
| Option | What it fixes | Risk |
|---|---|---|
| Memoize the single value | Nothing here: the notifications really do change | Wasted effort |
| Split into settings and notifications contexts | Table and header stop reacting to messages | More providers; a missing provider is a new bug class |
| Move notifications to an external store with a selector | Only the panel re-renders per message | A store to own and test |
| Throttle the push updates | Fewer renders per second | The panel shows stale data |
The decision
| Field | Decision record |
|---|---|
| Context | Settings change rarely; notifications change often; only one component shows notifications. |
| Decision | Keep user, theme and filters in a memoized context. Move notifications to a small store read through a selector hook. |
| Consequences, good | The table and header do not render per message; render-count tests state the contract. |
| Consequences, bad | Two state mechanisms to explain to new contributors. |
| Revisit when | Field data still shows slow interactions, or a third fast-changing slice appears. |
Rollout and verification
- Write the counting test first: push five messages and assert the header and table rendered once.
- Profile before and after on a production build with CPU throttling; counts show work was skipped, not how long it took.
- Ship behind a flag and compare interaction metrics, then test reconnect and unmount paths.
The 2 a.m. failure
After release the panel shows an empty list for users who open it right after login, then fills on the next message. The startup code that loads recent history pushes items into the existing notifications array and then calls setState((s) => s). The store skips updates that return the same reference, and the array it already handed out was mutated in place, so no subscriber is told.
First three checks: (1) confirm the store's state when the panel mounts; (2) read the history loader for in-place mutation; (3) check what the selector returned last. Mitigation: build a new array and state object in the loader. Follow-up: a test that mounts the panel after a load and asserts the list, and a review rule that store updates are immutable.
Architect's notes
- Separate by change rate before anything else. Settings and live data in one value will always fan out.
- Make the provider value a deliberate object. Memoize it, give it a name, and give the hook a clear error when no provider exists.
- Count renders in a test, then profile. A count shows the fan-out; only a profiler shows its cost.
- Prefer the smallest tool. A memoized value and a split are cheap; an external store is another system to own.
- Keep one source of truth. If data is in a store and in context, decide which wins.
Common mistakes
- Building the value object inline in a provider that re-renders for other reasons.
- Putting fast-changing data in the same context as static settings.
- Expecting
memoto stop a context update. - Writing a selector that returns a new object or array on every call.
- Forgetting the default only applies with no provider; a
value={undefined}provider overrides it. - Updating a store with a new object when nothing changed, which notifies subscribers for no reason.
Key takeaways
- A context reader re-renders when the provider's
valuechanges byObject.is. memodoes not stop a reader from receiving a fresh context value.- A fresh value object each provider render makes every reader re-render with no new data;
useMemofixes that. - Cost is changes times readers: cut the changes or cut the readers.
- Splitting state from dispatch lets dispatch-only components skip updates to the state.
- Reading in an outer component and passing a prop to a
memochild narrows a reader to a slice. useSyncExternalStorewith a selector subscribes to a slice of an external store; selectors must return stable values.- Render counts show fan-out; only a profiler shows its cost.
Practice exercises
Exercise 1 - Starter (30 min): Build a provider with two state values in one memoized object and two readers. Predict which reader renders when each state changes, then verify with render counters.
Exercise 2 - Intermediate (2 hours): Split that context into two and recount. Add a third reader that reads only a function from the context and decide what it takes to stop it re-rendering.
Exercise 3 - Advanced (half day): Replace one context with the lab's store and selector, and write a test that proves an unrelated update re-renders no reader. Note what you had to give up.
Related reading
In this course:
- The Event Loop and the Main Thread: why a long render delays input.
- Rendering Strategies: where React's render runs, on the server and in the browser.
- Core Web Vitals: the metrics that show whether a render change helped.
The previous lesson, Memoization and Controlling Re-renders in React, is published separately and is not linked here.
Official documentation
All pages read on 2026-11-08.
- React:
useContext, change detection, the closest provider and optimizing objects and functions. - React:
createContext, the default value and the React 19 provider syntax. - React:
memo, what still renders and reading context in an outer component. - React: Scaling Up with Reducer and Context, separate state and dispatch contexts.
- React:
useSyncExternalStore, snapshots and re-rendering on store changes. - React versions, the current release line.