Avoiding unnecessary re-renders through context splitting
A single large context feels convenient, until every component re-renders on every tiny change. Context splitting and real selectors fix the problem structurally instead of just treating the symptoms.
Table of Contents
- 1. The problem: one context, many consumers, every change hits all of them
- 2. One large context as the starting point
- 3. Why React does not compare selectively
- 4. Context splitting as a structural solution
- 5. Do not forget to memoize the provider value
- 6. Memoizing consumers with React.memo
- 7. Comparison to external state libraries with real selectors
- 8. When context splitting is enough and when a library makes sense
- 9. A practical checklist for context performance
- 10. Summary
- 11. FAQ
1. The problem: one context, many consumers, every change hits all of them
React does not resolve useContext selectively. As soon as the value a Provider passes down changes, React re-renders every component that consumes that context through useContext, regardless of whether the component actually uses the part of the value that changed. A context that bundles user, theme, and notifications together, for example, means a component that only reads theme still re-renders whenever a new notification arrives.
The pattern barely shows up in small applications, but quickly becomes a real performance problem as the component count grows. What makes it tricky is that the effect does not manifest as an obvious bug but as diffuse sluggishness: interactions feel slower, the React DevTools Profiler view shows a forest of grayed-out, unnecessarily rendered components, and nobody spots the cause at a glance because the code is functionally correct.
2. One large context as the starting point
A typical anti-pattern is the app-wide context that acts as a central store for almost every piece of global state. It is convenient because you only wire up one provider and get access everywhere via useContext(AppContext). That very convenience is the trap: every state change, no matter how small, produces a new context object, and React has no way of knowing that a consumer only cares about one slice of it.
The example below shows the classic setup. UserBadge only reads user, yet it re-renders every time notifications changes, because both values live in the same context object and React compares that object as a whole, not its individual fields.
// AppContext.jsx -- one context for everything
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const value = { user, setUser, theme, setTheme, notifications, setNotifications };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// UserBadge.jsx -- re-renders on EVERY notifications change
function UserBadge() {
const { user } = useContext(AppContext);
console.log('UserBadge rendering');
return <span>{user?.name}</span>;
}
3. Why React does not compare selectively
The reason lies in how useContext works: the hook subscribes to the entire context object, not to individual fields within it. If the reference of the value object passed by the Provider changes, React must notify every subscribed component that something might have changed and re-render them. There is no shallow comparison of individual fields, because the context itself has no notion of which parts of its value a component actually uses.
This is not a bug but a deliberate design decision: the Context API was originally built for rarely changing values like theming or localization, not as a full state management solution for frequently changing, granular data. If it gets used that way anyway, the missing selectivity has to be compensated for manually, either through architecture or through additional libraries.
4. Context splitting as a structural solution
The most direct fix is to split the one large context into several small, domain-focused contexts. Instead of a single AppContext, you end up with UserContext, ThemeContext, and NotificationContext, each with its own provider and its own value object. A component that only consumes UserContext will then only re-render on changes to the user object, changes to notifications no longer affect it at all.
It is important to split contexts by change frequency and domain cohesion, not purely by data type. Values that always change together can stay in the same context, because every additional context means additional provider nesting and more cognitive load when reading the tree. The goal is a balance: as many contexts as needed for render isolation, as few as possible for clarity.
// UserContext.jsx
const UserContext = createContext(null);
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = useMemo(() => ({ user, setUser }), [user]);
return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}
// NotificationContext.jsx -- separate, independent context
const NotificationContext = createContext(null);
function NotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const value = useMemo(
() => ({ notifications, setNotifications }),
[notifications]
);
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
}
// UserBadge.jsx -- no longer re-renders on notification changes
function UserBadge() {
const { user } = useContext(UserContext);
return <span>{user?.name}</span>;
}
5. Do not forget to memoize the provider value
Context splitting alone is not enough if the value object is recreated on every render of the provider. A literal like { user, setUser } written directly in JSX produces a new object reference on every render, even if user has not actually changed. React compares context values by reference, so consumers still re-render, even though the splitting step itself was correct.
The fix is useMemo around the value object, with the actual dependencies listed. Only then does the reference stay stable as long as the referenced values do not change, and consumers using the context truly only re-render when something meaningful has changed. This step is often forgotten in practice and quietly neutralizes many well-intentioned context splits.
6. Memoizing consumers with React.memo
Even with cleanly split and memoized contexts, a parent component can still pass unnecessary re-renders down to child components if those are not themselves memoized. React.memo prevents a child component from re-rendering as long as its props have not changed, complementing the context optimization at the component level.
Combining context splitting, useMemo for provider values, and React.memo for expensive child components produces render behavior that precisely tracks actual data changes instead of the shape of the provider tree. It is important to apply these optimizations selectively rather than preventively everywhere, since React.memo itself carries a small comparison overhead that is not worth it for trivial components.
const ExpensiveNotificationList = React.memo(function ExpensiveNotificationList({
notifications,
}) {
console.log('ExpensiveNotificationList rendering');
return (
<ul>
{notifications.map((n) => (
<li key={n.id}>{n.message}</li>
))}
</ul>
);
});
7. Comparison to external state libraries with real selectors
Libraries like Zustand or Jotai solve the selectivity problem in a fundamentally different way: they let you define selectors that subscribe only to the specific slice of state you need. A component calling useStore((state) => state.user) only re-renders when the result of that selector function actually changes, regardless of how many other fields in the global store change in parallel.
This is a fundamentally different mechanism than the Context API: instead of notifying the entire consumer tree on every value change, the library checks per selector whether the derived value has changed and only re-renders when it has. For applications with a lot of frequently changing global state, this is often the more pragmatic solution than ever finer-grained context splitting, because selectivity does not have to be earned through manual architecture work, it is built in.
8. When context splitting is enough and when a library makes sense
Context splitting is the right choice when global state stays manageable and can be cleanly divided into domain areas, such as user, theme, and language. It requires no extra dependency, stays close to React's standard toolkit, and is easy for new team members to follow because there is no additional API to learn.
Once state becomes complex, contains many independent, frequently changing fields, or needs to be read outside the React tree, for example in event handlers or utility functions, an external library with real selectors becomes more attractive. The switch is worth it as soon as you notice you are splitting contexts purely for performance reasons rather than domain necessity, because that is a sign the Context API has hit its structural limits.
9. A practical checklist for context performance
Before touching the context structure, it is worth opening the React DevTools Profiler: a single recording reliably shows which components re-render unnecessarily and whether a context is actually the cause. Without this measurement, there is a real risk of spending time on optimizations with no measurable effect while the actual problem lies elsewhere, for example in expensive calculations missing a useMemo.
The table below summarizes the key techniques and ranks them by effort and impact, so you can decide which step makes the most sense next for your own codebase.
| Technique | Effort | Impact | When to use |
|---|---|---|---|
useMemo in the provider |
low | prevents unnecessary reference changes | always, whenever a context value is an object |
| Context splitting | medium | isolates re-renders per domain | when consumers only use parts of the value |
React.memo on children |
low | stops propagation of unchanged props | for expensive child components |
| External library with selectors | high | fine-grained, built-in selectivity | for complex, frequently changing global state |
Mironsoft
React architecture, performance, and Magento frontend integration
React frontends that stay fast instead of slowing down with every feature?
We review existing React applications for unnecessary re-renders, bloated bundles, and fragile state management, then build a frontend that stays performant and connects cleanly to Magento or other backends.
Performance Audit
Systematically measuring and fixing re-renders, bundle size, and load times.
State Architecture
Cleanly separating context, client state, and server state instead of mixing everything.
Magento Integration
Building robust, type-safe GraphQL or REST integration with Magento.
10. Summary
Context Splitting: The Key Facts at a Glance
Core problem
useContext subscribes to the entire object, not individual fields, so every change re-renders all consumers.
First fix
Stabilize the provider value with useMemo before splitting anything.
Structural fix
Split large contexts into smaller, domain-focused contexts.
Alternative
For complex state, use a library with real selectors like Zustand or Jotai.