Context & Re-render Performance
The Context API is convenient, but it has a well-known performance characteristic that surprises many developers: when the context value changes, every single consumer re-renders, even if the component only uses one field of the context and that field did not change. Understanding this — and knowing how to work around it — is essential for building performant React applications.
Demonstrating the Problem
Consider a store context that holds both the product list and the current user's name. A component that only displays the username should not re-render when a product is added to the list — but with a naive context it will:
import { createContext, useContext, useState } from 'react'
const StoreContext = createContext(null)
function StoreProvider({ children }) {
const [products, setProducts] = useState([])
const [username, setUsername] = useState('Alice')
return (
<StoreContext.Provider value={{ products, setProducts, username, setUsername }}>
{children}
</StoreContext.Provider>
)
}
// This component only uses 'username'...
function UsernameDisplay() {
const { username } = useContext(StoreContext)
console.log('UsernameDisplay rendered') // fires on EVERY context change
return <p>Welcome, {username}</p>
}
// ...but it re-renders whenever 'products' changes too.
function ProductList() {
const { products, setProducts } = useContext(StoreContext)
function addProduct() {
setProducts(prev => [...prev, { id: Date.now(), name: 'New Product' }])
}
return (
<div>
{products.map(p => <p key={p.id}>{p.name}</p>)}
<button onClick={addProduct}>Add Product</button>
</div>
)
}
// Clicking "Add Product" re-renders UsernameDisplay — even though username is unchanged.Open the React DevTools Profiler and click "Add Product" — you will see UsernameDisplay highlighted in the flame graph even though its rendered output is identical. This wasted render is harmless for small trees, but it compounds in large applications with many consumers.
Solution 1 — Split into Multiple Contexts
The most effective fix is to separate the context by concern. Each context only re-renders its own subscribers:
const ProductsContext = createContext(null)
const UserContext = createContext(null)
function AppProvider({ children }) {
const [products, setProducts] = useState([])
const [username, setUsername] = useState('Alice')
return (
<UserContext.Provider value={{ username, setUsername }}>
<ProductsContext.Provider value={{ products, setProducts }}>
{children}
</ProductsContext.Provider>
</UserContext.Provider>
)
}
// Now UsernameDisplay subscribes ONLY to UserContext
function UsernameDisplay() {
const { username } = useContext(UserContext)
console.log('UsernameDisplay rendered') // only fires when username changes
return <p>Welcome, {username}</p>
}
// ProductList subscribes ONLY to ProductsContext
function ProductList() {
const { products, setProducts } = useContext(ProductsContext)
// adding a product does NOT re-render UsernameDisplay
}Solution 2 — Memoize the Context Value
Even without splitting contexts, you can prevent spurious re-renders caused by the Provider itself re-rendering (e.g. because its parent re-renders) by memoizing the value object:
import { useMemo } from 'react'
function StoreProvider({ children }) {
const [products, setProducts] = useState([])
const [username, setUsername] = useState('Alice')
// The value object is only recreated when products OR username changes.
// If the provider's parent re-renders for an unrelated reason, consumers
// do NOT re-render because the value reference is stable.
const value = useMemo(
() => ({ products, setProducts, username, setUsername }),
[products, username]
)
return (
<StoreContext.Provider value={value}>
{children}
</StoreContext.Provider>
)
}
// Note: this does NOT solve the cross-field problem.
// UsernameDisplay still re-renders when products changes,
// because both are in the same value object.Solution 3 — The Selector Hook Pattern
If splitting the context is not practical (e.g. it is a third-party context you do not control, or the fields are genuinely related), you can build a selector layer in front of useContext. The idea is to read the full context, extract only the needed slice, and wrap the consumer in React.memo so it only re-renders when that slice changes:
import { useContext, memo } from 'react'
// Generic selector hook
function useContextSelector(Context, selector) {
const value = useContext(Context)
return selector(value)
}
// Specific selectors — each returns a stable primitive or memoized reference
function useUsername() {
return useContextSelector(StoreContext, ctx => ctx.username)
}
function useProducts() {
return useContextSelector(StoreContext, ctx => ctx.products)
}
// Wrap consumers in memo so they bail out if the selected value is unchanged
const UsernameDisplay = memo(function UsernameDisplay() {
const username = useUsername()
return <p>Welcome, {username}</p>
})
// memo() bails out if 'username' is the same string reference.
// The component still *calls* useContext, but React skips reconciling
// the children if the returned JSX is identical.The limitation: this pattern works well when selectors return primitive values (strings, numbers, booleans). If your selector returns a new object or array every time, memo cannot bail out because reference equality fails. In that case, memoize the selector return value with useMemo inside the consumer, or use a dedicated state library.
Solution 4 — Zustand or Jotai for Built-in Selectors
When fine-grained subscriptions become a recurring concern, purpose-built state libraries offer the same developer experience as Context but with built-in selector support and zero wasted renders:
// Zustand — subscribe to exactly one slice
import { create } from 'zustand'
const useStore = create(set => ({
products: [],
username: 'Alice',
addProduct: product =>
set(state => ({ products: [...state.products, product] })),
setUsername: name => set({ username: name }),
}))
// UsernameDisplay subscribes ONLY to 'username'.
// Adding a product does not cause it to re-render at all.
function UsernameDisplay() {
const username = useStore(state => state.username) // selector
return <p>Welcome, {username}</p>
}
function ProductList() {
const products = useStore(state => state.products)
const addProduct = useStore(state => state.addProduct)
// ...
}// Jotai — atom-per-value; automatic fine-grained subscriptions
import { atom, useAtom } from 'jotai'
const usernameAtom = atom('Alice')
const productsAtom = atom([])
function UsernameDisplay() {
const [username] = useAtom(usernameAtom)
// Changing productsAtom never re-renders this component
return <p>Welcome, {username}</p>
}
function ProductList() {
const [products, setProducts] = useAtom(productsAtom)
// ...
}Decision Guide
Few consumers, rare updates — plain context with useMemo on the value is fine.
Many contexts, natural domain boundaries — split into multiple focused contexts.
Need selectors but want to stay with native React — selector hook pattern + React.memo.
Complex global state with frequent selective updates — Zustand or Jotai with built-in selectors.
Do not reach for libraries first — context splitting solves 80% of real-world cases.
Using the React DevTools Profiler
Always measure before optimising. Open the React DevTools browser extension, go to the Profiler tab, click Record, interact with your app, then stop recording. The flame graph shows every component that rendered and why. Bars highlighted in yellow indicate unexpected re-renders. Filter by "why did this render?" to see which context triggered the update.
A component that re-renders "because the parent did" but has identical props is a candidate for React.memo. A component that re-renders "because context changed" but only uses one field is a candidate for context splitting.