ReactContext & Re-render Performance

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:

JSX
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.

Warning
React does not do a "bail-out" check for context consumers the way it does for memoized components. Even if the rendered output would be identical, the consumer function still runs. For expensive render functions (complex calculations, large lists) this cost is real.
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:

JSX
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
}
Tip
Context splitting is the first thing to reach for. It is zero-cost, requires no extra libraries, and directly maps to the single-responsibility principle: one context, one concern.
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:

JSX
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.
Note
useMemo only helps with spurious re-renders from the Provider's parent re-rendering. It does NOT prevent a consumer from re-rendering when a different field in the same context value changes. For that, you need context splitting or the selector pattern.
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:

JSX
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:

JSX
// 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)
  // ...
}

JSX
// 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.

Note
Premature optimisation is the root of all evil. Context performance issues rarely matter until you have dozens of consumers or genuinely expensive render functions. Profile first, then apply the appropriate solution.