React Context Re-renders: Split Providers to Stop Unnecessary Updates
You added a global theme toggle using React Context and suddenly your entire app flickers on every keystroke in a search bar. Both values live in the same context, so every change to one triggers a re-render of everything subscribed to either. This is one of the most common performance traps in React apps beyond toy size.
Splitting your providers by concern is the fix. It's not complicated once you see the pattern, but the instinct to keep "all global state in one place" fights you every step of the way.
- Why React Context triggers re-renders and when that becomes a problem
- How to diagnose which components are re-rendering unnecessarily
- The provider-splitting pattern with a concrete before/after example
- Using
useMemoanduseCallbackto stabilize context values - When Context is the wrong tool entirely
How React Context Re-renders Actually Work
React's Context API works by broadcasting a value down the component tree. When that value changes β meaning React performs a strict equality check and finds the old and new references differ β every component that called useContext with that context will re-render, regardless of which part of the value it actually uses.
There is no built-in selector mechanism like Redux's useSelector. If a component consumes the context, it opts into re-rendering on every update. That's not a bug; it's the design. The performance cost only becomes noticeable when the value changes frequently or when many components are subscribed.
A context re-render propagates synchronously through the subtree. It bypasses
React.memoon any component that directly callsuseContextβ memo only helps children that receive props, not context consumers themselves.
Diagnosing the Problem Before You Fix It
Don't guess. Open React DevTools Profiler, record a short interaction, and look for components highlighted on every frame that shouldn't be doing work. The flame chart makes it obvious which subtrees are lighting up.
You can also add a quick console log inside a component to confirm:
function SearchBar() {
const { theme, user, searchQuery, setSearchQuery } = useAppContext();
console.log('SearchBar rendered'); // fires on every keystroke AND every theme change
// ...
}If you see that log firing when the user changes the theme, you have a splitting opportunity. Every log line you didn't expect is wasted CPU time.
The Root Cause: One Context Doing Too Much
Here's a typical context file in a medium-sized React app:
// AppContext.jsx β the "just put it all here" approach
import { createContext, useContext, useState } from 'react';
const AppContext = createContext(null);
export function AppProvider({ children }) {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const value = {
theme, setTheme,
user, setUser,
notifications, setNotifications,
searchQuery, setSearchQuery,
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
export const useAppContext = () => useContext(AppContext);Every time the user types a character β updating searchQuery β React creates a new object literal for value. That object has a new reference on every render. Every component consuming AppContext re-renders, including your Sidebar that only reads theme.
The Provider-Splitting Pattern
The fix is to separate concerns into independent context instances. Each context only changes when its own slice of state changes, so consumers that don't care about a particular slice are never touched.
// contexts/ThemeContext.jsx
import { createContext, useContext, useState } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);// contexts/SearchContext.jsx
import { createContext, useContext, useState } from 'react';
const SearchContext = createContext(null);
export function SearchProvider({ children }) {
const [searchQuery, setSearchQuery] = useState('');
return (
<SearchContext.Provider value={{ searchQuery, setSearchQuery }}>
{children}
</SearchContext.Provider>
);
}
export const useSearch = () => useContext(SearchContext);// main.jsx or App.jsx
import { ThemeProvider } from './contexts/ThemeContext';
import { SearchProvider } from './contexts/SearchContext';
import { UserProvider } from './contexts/UserContext';
function App() {
return (
<ThemeProvider>
<UserProvider>
<SearchProvider>
<AppShell />
</SearchProvider>
</UserProvider>
</ThemeProvider>
);
}Now SearchBar only subscribes to SearchContext. Typing a character updates SearchContext, and only consumers of that context re-render. Your Sidebar subscribes to ThemeContext alone and stays completely quiet during a search.
Stabilizing Values with useMemo
Even after splitting, there's a subtle trap: if your provider component itself re-renders for any reason (say, its parent re-renders), it creates a new object literal for value on every render, even when the underlying state hasn't changed. Wrap the value in useMemo to prevent this.
import { createContext, useContext, useState, useMemo } from 'react';
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
// setTheme is stable from useState, so it doesn't need to be a dep
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}The useMemo here is cheap β it only recomputes when theme actually changes. Functions like setTheme that come directly from useState are already stable references, so you don't need to wrap them in useCallback. For any custom callback you define inside the provider, useCallback is worth adding.
Splitting Read and Write Contexts
A further refinement β used in high-performance dashboards β is to separate the read value from the write functions into two contexts. Components that only dispatch actions never re-render when the state value changes.
import { createContext, useContext, useReducer, useMemo } from 'react';
const NotificationsStateContext = createContext(null);
const NotificationsDispatchContext = createContext(null);
function notificationsReducer(state, action) {
switch (action.type) {
case 'ADD': return [...state, action.payload];
case 'DISMISS': return state.filter(n => n.id !== action.payload);
default: return state;
}
}
export function NotificationsProvider({ children }) {
const [notifications, dispatch] = useReducer(notificationsReducer, []);
const stateValue = useMemo(() => ({ notifications }), [notifications]);
// dispatch is already stable from useReducer
return (
<NotificationsDispatchContext.Provider value={dispatch}>
<NotificationsStateContext.Provider value={stateValue}>
{children}
</NotificationsStateContext.Provider>
</NotificationsDispatchContext.Provider>
);
}
export const useNotifications = () => useContext(NotificationsStateContext);
export const useNotificationsDispatch = () => useContext(NotificationsDispatchContext);A button that only dispatches 'DISMISS' subscribes only to NotificationsDispatchContext. Adding a new notification never causes that button to re-render.
Common Pitfalls to Watch For
Creating new objects inline in JSX
This is an easy mistake that wipes out all your useMemo work:
// Bad β new object on every parent render
<ThemeContext.Provider value={{ theme, setTheme }}>
// Good β memoized reference
<ThemeContext.Provider value={memoizedValue}>Subscribing to the wrong context
After splitting, make sure each component imports from the right context module. It's easy to import the old monolithic hook out of habit. A quick search for your old useAppContext import after refactoring will catch any stragglers.
Over-splitting
Don't create a separate context for every single piece of state. Values that always change together should stay together. If user and authToken always update at the same time, one AuthContext is cleaner than two separate contexts that always fire in tandem anyway.
Forgetting memo on heavy child components
Splitting providers stops context consumers from re-rendering unnecessarily. But if a provider component's parent re-renders, the provider itself re-renders too (though useMemo keeps the value stable). Heavy child components that receive no props should still be wrapped with React.memo as a separate layer of defense.
When Context Is the Wrong Tool
Context is designed for low-to-medium frequency updates: theme, locale, authenticated user, feature flags. It's not designed for state that changes on every keystroke or every animation frame. If you find yourself splitting contexts across dozens of files to manage frequently-updating state, you're probably fighting the tool.
For high-frequency updates, consider: local component state (often the right answer and the simplest), a purpose-built state manager like Zustand or Jotai that supports selectors natively, or moving the hot state down to the components that actually need it rather than up into a provider.
Next Steps
- Open React DevTools Profiler on your app right now and record a typical user interaction. Look for components re-rendering that aren't visually changing.
- Identify any context that mixes concerns β auth state alongside UI state is the most common culprit. Split it.
- Add
useMemoto every context value object in your existing providers. This is a low-risk, high-value one-liner. - If you have a context that updates more than a few times per second, evaluate whether Zustand or local state would serve better.
- After refactoring, re-run the Profiler and compare flame charts to confirm the improvement before shipping.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!