Avoiding Context Re-Renders
React Context is the simplest way to share state across a component tree, but it has one sharp edge: when a context value changes, every component that consumes that context re-renders — regardless of whether it actually cares about the part that changed. In a large tree this can turn a single keystroke into hundreds of wasted renders. This page covers the practical techniques to keep context cheap: splitting contexts, memoizing the value, moving state down, selector patterns, and reaching for an external store when context isn’t the right tool.
Why context causes extra re-renders
Context propagation is not granular. When the value passed to a Provider changes by reference, React re-renders every consumer subscribed via useContext — even consumers that only read a field that didn’t change. React.memo does not stop this, because context subscription bypasses props.
A common mistake is creating a fresh object on every render:
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
// New object identity on EVERY render -> every consumer re-renders
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
Even if neither user nor theme changed, an unrelated parent re-render produces a new { ... } literal, and all consumers re-render.
Memoize the context value
The first fix is to stabilize the value’s identity with useMemo so it only changes when its inputs change:
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const value = useMemo(
() => ({ user, setUser, theme, setTheme }),
[user, theme]
);
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
Now an unrelated parent render no longer churns consumers. But this still has a flaw: changing theme re-renders components that only read user, because they share one object.
Split state and dispatch into separate contexts
A high-leverage pattern is to separate the data that changes often from the setters that never change. Setters from useState/useReducer are stable across renders, so a context that only exposes them never forces re-renders.
const StateContext = createContext(null);
const DispatchContext = createContext(null);
function CountProvider({ children }) {
const [count, setCount] = useState(0);
return (
<DispatchContext.Provider value={setCount}>
<StateContext.Provider value={count}>{children}</StateContext.Provider>
</DispatchContext.Provider>
);
}
// Reads value -> re-renders when count changes (correct)
function CountLabel() {
const count = useContext(StateContext);
return <p>Count: {count}</p>;
}
// Only dispatches -> never re-renders on count change
function IncrementButton() {
const setCount = useContext(DispatchContext);
return <button onClick={() => setCount((c) => c + 1)}>+1</button>;
}
IncrementButton stays static no matter how often count updates. Split further by domain too: keep UserContext, ThemeContext, and CartContext separate so a theme toggle never touches cart consumers.
Move state down (colocation)
Often the cheapest optimization is to not put the state in context at all. If only a small branch of the tree needs a value, lift the provider down to wrap just that branch — or keep the state local. The less of the tree a provider wraps, the fewer components can possibly re-render.
// Before: state at the root re-renders the whole app on every change.
function App() {
const [hovered, setHovered] = useState(false);
return (
<HoverContext.Provider value={hovered}>
<Sidebar />
<BigExpensiveContent />
<Card onHover={setHovered} />
</HoverContext.Provider>
);
}
// After: hover state lives where it's used; the rest of the app is untouched.
function App() {
return (
<>
<Sidebar />
<BigExpensiveContent />
<Card />
</>
);
}
function Card() {
const [hovered, setHovered] = useState(false);
return <div className={hovered ? "lift" : ""} onMouseEnter={() => setHovered(true)} />;
}
Selector patterns
The remaining limitation is that vanilla useContext cannot subscribe to part of a value. The community pattern is a selector hook that only re-renders when the selected slice changes. The lightweight library use-context-selector implements exactly this:
import { createContext, useContextSelector } from "use-context-selector";
const StoreContext = createContext(null);
function Profile() {
// Re-renders only when state.user.name changes, not on every store update.
const name = useContextSelector(StoreContext, (s) => s.user.name);
return <h1>{name}</h1>;
}
Without a library you can approximate this by splitting contexts finely, but selectors scale better when one provider holds many fields.
External stores as an alternative
When a value is read in many places and updates frequently, an external store is often a better fit than context. Stores like Zustand, Redux, Jotai, or Valtio give per-selector subscriptions out of the box, so each component re-renders only for the data it reads. React’s useSyncExternalStore is the underlying primitive.
import { create } from "zustand";
const useStore = create((set) => ({
count: 0,
user: null,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
function CountLabel() {
const count = useStore((s) => s.count); // subscribes to count only
return <p>{count}</p>;
}
Render of CountLabel happens only when `count` changes.
Components selecting `user` are not re-rendered by increment().
Comparison of approaches
| Approach | Granularity | Effort | Best for |
|---|---|---|---|
useMemo the value | Whole context | Low | Any provider (baseline) |
| Split state/dispatch | Per concern | Low | Stable setters + changing state |
| Move state down | Eliminated | Low | Localized state |
| Selector hook | Per field | Medium | Many fields, frequent reads |
| External store | Per field | Medium | App-wide, high-frequency state |
Tip:
React.memoon a consumer will not prevent context-driven re-renders. To skip work, the component must read less (split/selector) or the value must change less (memoize).
Gotcha: Memoizing the value is necessary but not sufficient. A memoized object still re-renders all consumers when any one field changes — only splitting or selectors fix per-field granularity.
Best Practices
- Always wrap multi-field context values in
useMemo(oruseReducer, which gives a stable dispatch) so unrelated parent renders don’t churn consumers. - Split state and dispatch into separate contexts; setters are stable and should never trigger re-renders.
- Separate contexts by domain so unrelated concerns (theme vs. cart vs. user) don’t re-render each other.
- Colocate state — move providers down to the smallest subtree that needs them, or keep state local entirely.
- Reach for a selector hook (
use-context-selector) when one provider exposes many independently-changing fields. - Prefer an external store (Zustand, Jotai, Redux) for app-wide, high-frequency state that needs per-field subscriptions.
- Profile with the React DevTools “Highlight updates” option to confirm a fix actually reduced re-renders.