Skip to content
React rc state-management 4 min read

Context Performance Pitfalls

React Context is a convenient way to share state without prop drilling, but it has a sharp performance edge that catches many teams off guard. The rule is simple and unforgiving: every component that consumes a context re-renders whenever the context value changes, regardless of which part of that value it actually uses. Understanding this behavior — and the patterns that tame it — is the difference between Context as a clean architectural tool and Context as a source of mysterious slowdowns.

How context propagates updates

When a Provider’s value changes by reference (a === comparison fails), React schedules a re-render for all consumers below it. There is no built-in partial subscription: a component reading only user.name re-renders just as eagerly as one reading the entire object. Even React.memo will not save a consumer, because consuming context bypasses the props-equality short-circuit entirely.

Consider a context that holds both rarely-changing user data and a frequently-ticking counter:

import { createContext, useContext, useState } from 'react';

const AppContext = createContext(null);

function AppProvider({ children }) {
  const [user, setUser] = useState({ name: 'Ada' });
  const [count, setCount] = useState(0);

  // New object on EVERY render — guarantees consumer re-renders.
  return (
    <AppContext.Provider value={{ user, setUser, count, setCount }}>
      {children}
    </AppContext.Provider>
  );
}

Every time count updates, the provider re-renders, builds a fresh value object, and forces every consumer to re-render — including ones that only care about user.

The new-object-each-render trap

The most common mistake is constructing the value inline. Even if none of the underlying state changed, { user, setUser } is a brand-new object each render, so the reference check always fails and consumers re-render needlessly. The fix is to memoize the value with useMemo.

import { createContext, useMemo, useState } from 'react';

const UserContext = createContext(null);

function UserProvider({ children }) {
  const [user, setUser] = useState({ name: 'Ada' });

  // Stable reference: only changes when `user` changes.
  const value = useMemo(() => ({ user, setUser }), [user]);

  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

Tip: setUser from useState is guaranteed stable across renders, so it does not need to appear in the dependency array. Only include values that can actually change.

Splitting state and dispatch contexts

Memoizing helps, but it does not solve the bundled-data problem: when user does change, consumers reading only setUser still re-render. A powerful pattern is to split a single context into two — one for the state that changes, and one for the stable dispatch/setters that never do. Components that only dispatch actions never re-render when state changes.

import { createContext, useContext, useReducer } from 'react';

const StateContext = createContext(null);
const DispatchContext = createContext(null);

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    default:
      return state;
  }
}

export function CounterProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, { count: 0 });

  // `dispatch` is referentially stable, so its context value never changes.
  return (
    <DispatchContext.Provider value={dispatch}>
      <StateContext.Provider value={state}>{children}</StateContext.Provider>
    </DispatchContext.Provider>
  );
}

export const useCounterState = () => useContext(StateContext);
export const useCounterDispatch = () => useContext(DispatchContext);

Now a button that only calls dispatch subscribes to DispatchContext and is completely insulated from state churn:

function IncrementButton() {
  const dispatch = useCounterDispatch();
  console.log('IncrementButton render');
  return <button onClick={() => dispatch({ type: 'increment' })}>+1</button>;
}

Output:

IncrementButton render   // logged once, on mount only

No matter how many times the count changes, this button never re-renders.

Selector-based subscriptions

Splitting contexts works well for a handful of slices, but it scales poorly when a single store has many fields. Libraries like use-context-selector bring fine-grained, selector-based subscriptions to Context: a consumer re-renders only when the selected slice changes.

import { createContext, useContextSelector } from 'use-context-selector';

const StoreContext = createContext(null);

function UserName() {
  // Re-renders ONLY when `state.user.name` changes.
  const name = useContextSelector(StoreContext, (state) => state.user.name);
  return <span>{name}</span>;
}

This gives you Redux-style selector ergonomics while staying within the Context API. The table below compares the strategies.

StrategyGranularityExtra depsBest for
Memoize valueProvider-wideNoneAny context — the baseline fix
Split state/dispatchTwo slicesNoneAction-heavy UIs, reducers
use-context-selectorPer-fieldOne libraryLarge stores, many fields
Dedicated store (Zustand/Jotai)Per-atom/selectorOne libraryComplex global state

Best Practices

  • Always wrap a non-primitive context value in useMemo to give it a stable reference.
  • Keep stable setters (dispatch, useState setters) out of useMemo dependency arrays.
  • Split changing state from stable dispatch into separate providers so action-only components never re-render.
  • Reach for use-context-selector when a single store has many independent fields read by many components.
  • Place providers as low in the tree as possible so fewer components sit inside the re-render blast radius.
  • If you find yourself fighting Context performance constantly, consider a dedicated store library built for selector subscriptions.
Last updated June 14, 2026
Was this helpful?