< Home
React Context is simple. Until it isn’t
By Brian Ochan | 27/03/2026
React Context feels like one of the simplest tools in React. No libraries, no setup.
Just create a provider, wrap part of the tree, and share state. It works, until the UI around it starts to grow. I was working on a fairly complex interface where multiple parts of the tree needed access to shared state. Things like:
- a list of items
- the currently selected item
- filters and derived values
Context felt like the right abstraction. So we introduced a provider:
<ItemsProvider
value={{
items,
selectedItem,
setSelectedItem,
filters,
setFilters,
}}
>
{children}
</ItemsProvider>
Clean. Centralized. Easy to consume. And for a while, it worked. But over time, we started noticing subtle issues:
- components re-rendering more often than expected
- UI updates that felt wider than the change itself
- performance degrading in places that were not obviously connected
Nothing was broken, but something was fundamentally off. If you peel back the layers, the behavior is simpler and more mechanical than it first appears. React compares the previous context value with the next one using Object.is. If that value is different, React updates every component below that provider that reads from that context. Not some of them. Not only the ones using one specific field. All of them.
graph TD A[ItemsProvider] --> B[Component A uses selectedItem] A --> C[Component B uses filters] A --> D[Component C uses items]
When you look at the diagram above, it is easy to assume that if selectedItem changes, only the component reading selectedItem should re-render.
flowchart TD A[setSelectedItem called] --> B[new value object created] B --> C[value !== previousValue] C --> D[All consumers notified]
But what actually happens is closer to this, conceptually:
- Component A re-renders as expected
- Component B also re-renders, even though it did not need to
- Component C also re-renders, even though it did not need to
This was also the point where React DevTools helped turn a vague feeling into something concrete. Nothing was crashing. There was no obvious bug. The interface just felt busier than it should have. A small interaction would happen, and a much larger part of the screen seemed to wake up with it.
So I opened the Profiler and recorded the interaction. First, change selectedItem. Stop the recording. Look at what moved. That was the moment the shape of the problem became easier to see. Components that only cared about filters or items were still being pulled into the update. The change was not staying local. It was spreading.
The Components tab made the second half of it clearer. You could inspect the provider, watch the context value change, and notice that React was being handed a fresh object reference. At that point, the behavior stopped feeling mysterious because DevTools did not reveal a broken abstraction. It revealed a broadcast pattern doing exactly what it was designed to do.
Once that clicked, the real issue became easier to name. It was not Context itself. It was the way we were using it:
value={{
items,
selectedItem,
setSelectedItem,
filters,
setFilters,
}}
Every render creates a new object, even if only selectedItem changed:
- the object reference changes
- React treats it as a completely new context value
- all consumers are notified
The behavior is correct. The surprise comes from the size of the audience. A common instinct at that point is to reach for useMemo, and we did too:
const value = useMemo(
() => ({
items,
selectedItem,
setSelectedItem,
filters,
setFilters,
}),
[items, selectedItem, filters],
);
This helps, but only partially because:
- any dependency change still invalidates the whole object
- the granularity is still too coarse
- consumers still cannot subscribe to only one slice of the value
It does reduce frequency but does not change the model. That was the real mental shift for me.
React Context is not selector-based. It is a broadcast mechanism.
It does not track which component uses which field. It does not know what part of the value changed. It only knows that the value reference changed, and that is enough to notify every component below the provider that reads from that context.
That is what makes the API feel so small and the behavior feel so wide. The surface area is tiny but the consequences are not.
What I liked, once I went back to the React docs, was realizing this was not some strange private theory. The behavior is right there in the model. If a provider receives a different value, React updates the components below it that read from that context. The docs also call out the performance implications of passing objects and functions through context because a new identity is enough to cause consumers to re-render.
That is also why useMemo helps less than people hope. It can stabilize the value when nothing relevant has changed, but the moment one dependency changes, the value changes again and the consumers are notified again.
Even React's guidance around memo points in the same direction. If you want finer-grained control, you split things up. The outer component can read from context, and a smaller memoized child can receive only the prop it actually needs.
That changed the way I started thinking about Context in larger interfaces. We stopped treating it like a global store and made the flow more explicit.
One part of that was splitting contexts by concern:
<ItemsProvider value={items}>
<SelectedItemProvider value={selectedItem}>
<FiltersProvider value={filters}>{children}</FiltersProvider>
</SelectedItemProvider>
</ItemsProvider>
Once we did that, changing selectedItem no longer affected components that only cared about filters.
The other part was separating state from actions instead of bundling everything into a single object like this:
value={{ state, actions }}
In practice, that made updates easier to reason about and reduced unnecessary churn.
Over time, I became more intentional about where Context actually belonged. I started to see it less as a place to own all the state, and more as a last-mile delivery mechanism. State should usually live closer to where it changes. Context can still distribute it, but that is a very different posture from centralizing everything by default.
That shift made the system feel more predictable and more local.
graph TD A[SelectedItemProvider] --> B[Component A re-renders] A -.-> C[Component B unaffected] A -.-> D[Component C unaffected]
It also made the update path easier to reason about. Context feels simple because the API is small, but its behavior is global. Over time, I have come to think of it less as state management and more as a broadcast mechanism whose trade-offs only really show up as a system grows.
If you want to go deeper, the React docs on context, useContext, React Developer Tools, the Profiler, and memo are all worth reading alongside this.