Context Re-render Problem and Splitting
The Hidden Bomb in Your Context
You create a context with a few values. Everything works. Then the app grows, more components consume it, and suddenly every keystroke re-renders 40 components. The culprit: a single context that mixes frequently-changing and rarely-changing values.
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const [searchQuery, setSearchQuery] = useState(''); // Changes on every keystroke
return (
<AppContext.Provider value={{
user, setUser,
theme, setTheme,
notifications, setNotifications,
searchQuery, setSearchQuery, // This bombs every consumer
}}>
{children}
</AppContext.Provider>
);
}
Every keystroke in the search input calls setSearchQuery, which re-renders AppProvider, which creates a new value object, which triggers re-renders in EVERY component that calls useContext(AppContext) — even those that only read theme or user.
The Mental Model
Think of a React context as a radio station. Every component that calls useContext is tuned to that station. When the station broadcasts a signal (value changes), every radio receiver activates — even if the message isn't relevant to them.
If one station broadcasts weather, sports, and breaking news, every listener hears everything. A weather app re-renders when sports scores update. A sports widget re-renders when the weather changes.
Context splitting is creating separate stations: one for weather, one for sports, one for breaking news. Each listener tunes to only the station they need.
Why Context Triggers All Consumers
React's context implementation is simple: when the Provider's value prop changes (via Object.is), React walks the fiber tree to find all consumers and schedules re-renders for each one.
// Simplified from React source
function propagateContextChange(workInProgress, context, renderLanes) {
let fiber = workInProgress.child;
while (fiber !== null) {
// Check if this fiber reads the context
const dependency = fiber.dependencies;
if (dependency !== null) {
if (dependency.contexts.includes(context)) {
// Force a re-render on this consumer
scheduleUpdateOnFiber(fiber, renderLanes);
}
}
// Continue tree traversal...
fiber = getNextFiber(fiber);
}
}
There's no selectivity. React doesn't check which part of the context value a consumer reads. It doesn't do fine-grained subscription. If the context reference changed, ALL consumers render.
Solution 1: Split Contexts by Update Frequency
Separate fast-changing values from slow-changing ones:
// Split into multiple contexts by change frequency
const UserContext = createContext(null); // Changes: on login/logout
const ThemeContext = createContext('light'); // Changes: on toggle
const SearchContext = createContext(''); // Changes: on every keystroke
const NotificationContext = createContext([]); // Changes: on new notification
function AppProviders({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [search, setSearch] = useState('');
const [notifications, setNotifications] = useState([]);
return (
<UserContext.Provider value={useMemo(() => ({ user, setUser }), [user])}>
<ThemeContext.Provider value={useMemo(() => ({ theme, setTheme }), [theme])}>
<NotificationContext.Provider value={useMemo(() => ({ notifications, setNotifications }), [notifications])}>
<SearchContext.Provider value={useMemo(() => ({ search, setSearch }), [search])}>
{children}
</SearchContext.Provider>
</NotificationContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
Now keystroke updates only re-render components that consume SearchContext. UserAvatar (reads UserContext) and ThemeToggle (reads ThemeContext) are unaffected.
Solution 2: Split State from Dispatch
This one's clever, and you'll want to remember it. Separate the read-only state from the update functions:
const TodoStateContext = createContext(null);
const TodoDispatchContext = createContext(null);
function TodoProvider({ children }) {
const [todos, dispatch] = useReducer(todoReducer, []);
return (
<TodoStateContext.Provider value={todos}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
// Components that only ADD todos don't re-render when the list changes
function AddTodoButton() {
const dispatch = useContext(TodoDispatchContext); // dispatch is stable!
return <button onClick={() => dispatch({ type: 'add', text: 'New' })}>Add</button>;
}
// Components that DISPLAY todos re-render when the list changes
function TodoList() {
const todos = useContext(TodoStateContext);
return todos.map(t => <TodoItem key={t.id} todo={t} />);
}
dispatch from useReducer is referentially stable — it never changes. So TodoDispatchContext's value never changes, and AddTodoButton never re-renders from context changes.
Solution 3: Memoize the Provider Value
At minimum, always memoize the context value object:
// BAD: new object on every render
function Provider({ children }) {
const [count, setCount] = useState(0);
return (
<MyContext.Provider value={{ count, setCount }}>
{children}
</MyContext.Provider>
);
}
// GOOD: memoized object, only changes when count changes
function Provider({ children }) {
const [count, setCount] = useState(0);
const value = useMemo(() => ({ count, setCount }), [count]);
return (
<MyContext.Provider value={value}>
{children}
</MyContext.Provider>
);
}
Memoizing the value prevents unnecessary context propagation when the Provider's PARENT re-renders but the context state hasn't changed. Without memoization, every parent re-render creates a new value object, triggering all consumers even though nothing in the context state changed.
But memoization does NOT help when the context state actually changes. If count changes, all consumers still re-render — whether they use count or not.
Solution 4: Component-Level Memoization Barrier
Wrap consumers in memo to prevent cascading:
const MemoizedHeader = memo(function Header() {
const { theme } = useContext(ThemeContext);
return <header className={theme}>...</header>;
});
This only helps against parent re-renders, not context re-renders. If ThemeContext changes, MemoizedHeader still re-renders because context bypasses memo. Memo only prevents re-renders from parent component re-renders.
Production Scenario: The 200ms Keystroke
This is a real story I've seen play out at multiple companies. A team uses a single "global" context for app state:
function GlobalProvider({ children }) {
const [state, dispatch] = useReducer(globalReducer, initialState);
return (
<GlobalContext.Provider value={{ state, dispatch }}>
{children}
</GlobalContext.Provider>
);
}
// 40+ components consume this context
function SearchInput() {
const { state, dispatch } = useContext(GlobalContext);
return (
<input
value={state.searchQuery}
onChange={e => dispatch({ type: 'SET_SEARCH', payload: e.target.value })}
/>
);
}
Every keystroke dispatches SET_SEARCH, which creates a new state object, which creates a new context value, which re-renders all 40+ consumers. Each consumer is a dashboard widget with charts and tables. Total re-render time: 200ms per keystroke. The UI feels frozen.
The fix:
// 1. Move search to its own context
const SearchContext = createContext({ query: '', setQuery: () => {} });
// 2. Keep dashboard data in a separate context
const DashboardContext = createContext(null);
// 3. Search input only consumes SearchContext
function SearchInput() {
const { query, setQuery } = useContext(SearchContext);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
// Dashboard widgets only consume DashboardContext — unaffected by typing
Keystroke latency drops from 200ms to 2ms.
Why React doesn't support context selectors
Libraries like Redux have useSelector(state => state.user.name) — the component only re-renders when state.user.name changes. React's useContext doesn't support this.
The React team considered and rejected built-in selectors for context. The reason: selectors require either:
- Object identity tracking — tracking which properties a component accesses (complex, fragile)
- Selector functions —
useContext(ctx, state => state.name)would need memoization of selectors and deep comparison of results
Both add complexity to React's core. The recommended solution is split contexts (simpler, more explicit) or external state management libraries (zustand, jotai) that implement selectors.
The use() hook in React 19 reads context but doesn't add selector support either. Context remains an all-or-nothing subscription.
Common Mistakes
| What developers do | What they should do |
|---|---|
| Putting all app state into a single context A single context means every state change re-renders every consumer. Split contexts limit re-renders to relevant consumers | Split contexts by domain and update frequency. Separate fast-changing from slow-changing values |
| Not memoizing the context Provider value Without memoization, parent re-renders create new value references, triggering all consumers even when context state hasn't changed | Always useMemo the value object passed to Provider |
| Using React.memo to prevent context-triggered re-renders Context propagation is a separate mechanism from props. React finds consumers via fiber tree walking, regardless of memo boundaries | memo prevents parent-triggered re-renders, not context-triggered ones. Context bypasses memo |
| Passing inline objects as context value: value={{ user, theme }} Inline objects create new references every render. Even if user and theme haven't changed, the new object reference triggers all consumers | Memoize: value=`{useMemo(() => ({ user, theme }`), [user, theme])} |
Challenge
Challenge: Fix the context performance issue
// This dashboard has a performance problem.
// Typing in the search box causes all widgets to re-render.
// Fix it by restructuring the context.
const DashboardContext = createContext(null);
function DashboardProvider({ children }) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedWidget, setSelectedWidget] = useState(null);
const [dateRange, setDateRange] = useState({ start: '2024-01', end: '2024-12' });
const [refreshKey, setRefreshKey] = useState(0);
return (
<DashboardContext.Provider value={{
searchQuery, setSearchQuery,
selectedWidget, setSelectedWidget,
dateRange, setDateRange,
refreshKey, refresh: () => setRefreshKey(k => k + 1),
}}>
`{children}`
</DashboardContext.Provider>
);
}
// Used by: SearchBar (searchQuery), WidgetGrid (selectedWidget, dateRange),
// RefreshButton (refresh), 20 individual Widget components (dateRange, refreshKey)
Show Answer
// Split by update frequency
const SearchContext = createContext(null); // Fast: every keystroke
const SelectionContext = createContext(null); // Medium: on widget click
const DataContext = createContext(null); // Slow: date range + refresh
function DashboardProvider({ children }) {
const [searchQuery, setSearchQuery] = useState('');
const [selectedWidget, setSelectedWidget] = useState(null);
const [dateRange, setDateRange] = useState({ start: '2024-01', end: '2024-12' });
const [refreshKey, setRefreshKey] = useState(0);
const searchValue = useMemo(() => ({ searchQuery, setSearchQuery }), [searchQuery]);
const selectionValue = useMemo(() => ({ selectedWidget, setSelectedWidget }), [selectedWidget]);
const dataValue = useMemo(
() => ({ dateRange, setDateRange, refreshKey, refresh: () => setRefreshKey(k => k + 1) }),
[dateRange, refreshKey]
);
return (
<SearchContext.Provider value={searchValue}>
<SelectionContext.Provider value={selectionValue}>
<DataContext.Provider value={dataValue}>
{children}
</DataContext.Provider>
</SelectionContext.Provider>
</SearchContext.Provider>
);
}Now typing only re-renders SearchBar (consumes SearchContext). The 20 widget components consume DataContext and only re-render when dateRange or refreshKey changes. Widget selection only affects components consuming SelectionContext.
Quiz
Key Rules
- 1When a context value changes, ALL consumers re-render — even if they only use a subset of the value.
- 2Split contexts by update frequency. Fast-changing values (search, hover) should be in separate contexts from slow-changing values (theme, user).
- 3Always memoize Provider values: value=
{useMemo(() => ({ state, dispatch }), [state])}. Prevents unnecessary propagation on parent re-renders. - 4Separate state from dispatch. useReducer's dispatch is referentially stable — put it in its own context for write-only consumers.
- 5React.memo does NOT protect against context re-renders. It only prevents parent-triggered re-renders.
- 6For fine-grained subscriptions (selector pattern), use external state managers (zustand, jotai) instead of context.