Skip to content
React rc performance 5 min read

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

ApproachGranularityEffortBest for
useMemo the valueWhole contextLowAny provider (baseline)
Split state/dispatchPer concernLowStable setters + changing state
Move state downEliminatedLowLocalized state
Selector hookPer fieldMediumMany fields, frequent reads
External storePer fieldMediumApp-wide, high-frequency state

Tip: React.memo on 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 (or useReducer, 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.
Last updated June 14, 2026
Was this helpful?