Skip to content

    Frontend System Design

    Context and Its Render Cost in React

    Learn why every reader of a React context re-renders when its value changes, and how stable values, split contexts and selectors cut the fan-out, with tests.

    By Published 13 min read

    • frontend-system-design
    • react
    • context
    • rendering
    • state-management
    Share

    Standard lessonintermediateReact labFrontend System Design

    Before this: Memoization and Controlling Re-renders

    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

    1. A context reader renders when the provider's value changes. React compares the previous and next value with Object.is. If they differ, every component that reads that context re-renders.
    2. memo does not shield a reader. React's useContext reference says "skipping re-renders with memo does not prevent the children receiving fresh context values."
    3. 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

    A context value change re-renders every reader below the provider, not the components that do not read itProvider state to Context value (new value); Context value to Reader: user name (changed); Context value to Reader: theme toggle (changed); Context value to Non-reader: footer (no read)BELOW THE PROVIDERProvider state: theme changesProvider statetheme changesContext value: Object.is checkContext valueObject.is checkReader: user name: re-rendersReader: usernamere-rendersReader: theme toggle: re-rendersReader: themetogglere-rendersNon-reader: footer: not calledNon-reader:footernot callednew valuechangedchangedno read

    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

    sequenceDiagram accTitle: A context update reaching readers accDescr: A click updates state in the provider, the provider renders with a new value, React compares it to the previous one with Object.is, and every reader re-renders even when it is wrapped in memo or uses only an unchanged part of the value. participant U as User participant P as Provider participant R as React participant A as Reader (uses user) participant B as Reader (uses theme) U->>P: toggles theme P->>R: render, value is a new object R->>R: Object.is(old, new) is false R->>A: re-render (even inside memo) R->>B: re-render

    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

    flowchart TD accTitle: Choosing how to cut context re-renders accDescr: First stabilise the value so it changes only with data. If readers still re-render for parts they do not use, split the context by concern, or read it in an outer component and pass a slice to a memo child, or move fast-changing data to an external store with selectors. A["Readers re-render
    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.

    1. Read src/contexts.tsx and src/store.tsx beside test/context.test.tsx. For each test, write down your predicted counts first.
    2. 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).
    3. Compare the "split" and "combined" task tests and explain the difference using the Object.is rule.

    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

    NOTE

    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

    Starting architecture: live notifications and static settings share one contextPush connection to AppProvider (message); AppProvider to Header; AppProvider to Data table; AppProvider to Notifications panelEVERY READER RE-RENDERSPush connection: new notificationPushconnectionnew notificationAppProvider: one value objectAppProviderone value objectHeader: reads userHeaderreads userData table: reads filtersData tablereads filtersNotifications panel: reads the listNotificationspanelreads the listmessage

    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

    1. Write the counting test first: push five messages and assert the header and table rendered once.
    2. Profile before and after on a production build with CPU throttling; counts show work was skipped, not how long it took.
    3. 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 memo to 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 value changes by Object.is.
    • memo does 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; useMemo fixes 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 memo child narrows a reader to a slice.
    • useSyncExternalStore with 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.

    In this course:

    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.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments