Context is React’s built-in way to pass data down without threading props through every level. It’s also the source of a familiar performance complaint: “I added a cart to our app context and now typing in the search box re-renders half the page.” Nothing is wrong with React. Context has a simple update rule, and a single context holding everything breaks it.
Interviewers ask about this because the fix is easy to state and easy to get partly wrong. You need to explain the rule, show a split that actually helps, and know when context is the wrong tool.
The rule that causes the re-renders
When a provider renders with a new value, React compares it to the previous value with Object.is. If it differs, every component that calls useContext for that context re-renders. React doesn’t look at which fields the component used. A component that reads only theme from { cart, theme, addItem } still re-renders when cart changes, because the object is new.
React.memo doesn’t help here. memo skips a re-render when props are unchanged, but a context change is not a prop change; React re-renders the consumer regardless.
The common way this goes wrong is a provider that builds its value inline:
function ShopProvider({ children }) {
const [cart, setCart] = useState([]);
const [theme, setTheme] = useState('light');
const value = { cart, theme, addItem: (sku) => setCart((c) => [...c, sku]), setTheme };
return <ShopContext value={value}>{children}</ShopContext>;
}(React 19 lets you render the context itself as the provider, <ShopContext value={...}>; on 18 you’d write <ShopContext.Provider>. They behave the same.)
Every render of ShopProvider creates a new object and a new addItem function, so every consumer re-renders on every cart change, theme change or anything else that re-renders the provider.
Measuring the problem
Here’s a small shop with four components under that provider: a theme toggle that reads theme, a cart badge that reads cart, an “Add” button that reads addItem, and a product grid that reads nothing from context. All four are wrapped in memo. Rendering it with React 19 in jsdom and counting renders after one click on “Add”:
A one context, memo children: badge 1 {"ThemeToggle":1,"CartBadge":1,"AddButton":1}Three components re-rendered for a change only the badge cares about. The theme toggle’s theme didn’t change. The button’s behaviour didn’t change, it just received a new function with the same meaning. The product grid didn’t re-render, which is the one piece of good news, and we’ll come back to why.
Three re-renders of tiny components is nothing. The same pattern with a product list of 300 cards, each reading addItem from context, means 300 card re-renders every time anything in the cart changes. That’s when it shows up in the Profiler.
Splitting by rate of change and by reader
The fix is to stop putting things with different lifetimes in one value. Look at the shop’s data and ask two questions: how often does it change, and who reads it?
cartchanges whenever the user adds or removes something. Read by the badge and the cart drawer.addItemnever needs to change. Read by every product card.themechanges rarely. Read by a handful of layout components.
That suggests three contexts:
const CartContext = createContext([]);
const CartActionsContext = createContext(null);
const ThemeContext = createContext('light');
function cartReducer(cart, action) {
switch (action.type) {
case 'add': return [...cart, action.sku];
case 'remove': return cart.filter((sku) => sku !== action.sku);
default: return cart;
}
}
export function ShopProvider({ children }) {
const [cart, dispatch] = useReducer(cartReducer, []);
const [theme, setTheme] = useState('light');
const actions = useMemo(
() => ({
addItem: (sku) => dispatch({ type: 'add', sku }),
removeItem: (sku) => dispatch({ type: 'remove', sku }),
}),
[],
);
return (
<ThemeContext value={theme}>
<CartActionsContext value={actions}>
<CartContext value={cart}>{children}</CartContext>
</CartActionsContext>
</ThemeContext>
);
}The decisions that make it work:
Actions get their own context, and their value is stable. dispatch from useReducer never changes identity, so a useMemo with an empty dependency list produces one actions object for the provider’s lifetime. Components that only add items (all 300 product cards) subscribe to CartActionsContext and never re-render because of cart contents. This state/dispatch split is the most valuable one, and it’s why useReducer pairs so well with context.
Each context’s value is a primitive or a stable reference. theme is a string; cart is the reducer’s array, which only changes when the cart does. No inline objects.
Consumers read the narrowest context. The badge reads CartContext; the theme toggle reads ThemeContext.
The same click with this version, and no memo anywhere:
B split contexts, no memo: badge 1 {"CartBadge":1}Only the badge re-rendered. Wrap the reads in small hooks (useCart(), useCartActions(), useTheme()) that throw a clear error when used outside the provider, so components never import the raw contexts.
Why the product grid never re-rendered
In both versions the grid stayed put, even without memo in version B. That’s because of where the elements are created. The tree was rendered as:
<ShopProvider>
<ThemeToggle /><CartBadge /><AddButton /><ProductGrid />
</ShopProvider>Those child elements are created by the parent of ShopProvider and passed in as children. When the provider’s state changes, it re-renders and returns the same children prop it received, the same element objects as before. React sees identical elements and skips them, unless they consume a context that changed. So a provider component that takes children doesn’t cascade re-renders into its subtree; only the context consumers update.
The anti-pattern is putting state and its consumers’ parent in the same component, for example an App that holds useState for the cart and renders the whole layout inline. Then every cart change re-renders App, which re-creates every element below it. Moving the state into a dedicated provider component that receives children fixes that without touching any consumer.
When splitting isn’t enough
Context has no selectors. A component can’t say “re-render me only when cart.length changes”; it re-renders when the CartContext value changes, full stop. If many components each need a different slice of frequently changing shared state (a spreadsheet’s cell values, a real-time dashboard), you end up with dozens of tiny contexts or a lot of wasted renders.
That’s where an external store fits better: Zustand, Redux Toolkit, Jotai, or your own store read with useSyncExternalStore, where each component subscribes with a selector and re-renders only when its selected value changes. Context remains the right tool for dependency injection and slow-changing values such as theme, locale, the current user and feature flags.
And measure before restructuring. Open React DevTools, record with the Profiler, enable “Record why each component rendered”, and confirm that context changes are actually the expensive part. With the React Compiler enabled, much component-level memoisation happens automatically, but the compiler doesn’t change the context rule: a new context value still re-renders every consumer.
Interview exercise: the slow product list
“Our AppContext holds { user, cart, addToCart, searchQuery, setSearchQuery }. The search box sets searchQuery on each keystroke. Product cards use addToCart from the context. Typing feels laggy on a page with 200 cards. Explain and fix.”
Answer. Every keystroke changes searchQuery, which creates a new AppContext value object, so all 200 cards re-render because they consume the context, even though they only use addToCart, which hasn’t changed in meaning. memo on the cards won’t help, since context changes bypass it. I’d first check whether searchQuery needs to be in context at all: if only the search box and the results list use it, it should be local state in their nearest common parent. Then I’d split what remains: addToCart and other cart actions into a CartActionsContext with a stable value (built from useReducer’s dispatch inside useMemo), cart into its own context for the badge and drawer, and user into its own slow-changing context. After the change, I’d record a Profiler session while typing to confirm the cards no longer render on each keystroke. If the search results themselves are expensive to render, useDeferredValue on the query would keep the input responsive as a separate improvement.