#Next.js#React

React Context Performance Pitfalls and Optimization

Context is easy to use, but when its value changes, every consumer rerenders. In high-frequency scenarios, that is a performance killer. This article explains Context rerendering, why putting all state into one Context is an anti-pattern, how to split it, and when to switch to Zustand.

Key Points

  • Topic Focus: Next.js、React
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

React Context Performance Pitfalls and Optimization

1. The Value and Hidden Cost of Context

Before Context, there were basically two ways to pass values across components: pass props through every layer (prop drilling), or implement your own publish-subscribe system. The former becomes frustrating by the third layer, while the latter is simply reinventing the wheel. Context made "global state" lightweight:

tsx
const ThemeContext = createContext({ theme: 'light', toggle: () => {} });

function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider
      value={{
        theme,
        toggle: () => setTheme((t) => (t === 'light' ? 'dark' : 'light')),
      }}
    >
      <Layout />
    </ThemeContext.Provider>
  );
}

function Footer() {
  const { theme } = useContext(ThemeContext);
  return <footer className={theme}>...</footer>;
}

Clean, intuitive, and no need to pass values through every layer. React officially positions it as a "dependency injection" mechanism rather than a "state management library". That distinction is critical, and we will return to it repeatedly.

But Context has a widely underestimated hidden cost: it is fundamentally a "broadcast" mechanism, not a "subscription" mechanism. As soon as the value attached to a Provider changes (its reference changes), React finds every component consuming that Context and marks all of them for rerendering, regardless of which field in value they actually use. This is the root of every Context performance problem.

2. The Rerendering Mechanism: Why Does It Rerender When "The Field I Use Hasn't Changed"?

First, look at this classic gotcha:

tsx
// Anti-pattern: value is a new object on every Provider rerender
function App() {
  const [user, setUser] = useState({ name: 'Alice', age: 28 });
  const [theme, setTheme] = useState('light');

  return (
    <AppContext.Provider value={{ user, theme, setUser, setTheme }}>
      <Header />  {/* only uses theme */}
      <Profile /> {/* only uses user */}
      <Cart />    {/* uses nothing, but is still in the tree */}
    </AppContext.Provider>
  );
}

There are two fatal issues here.

First, value is an inline object literal. Every time App rerenders (for example, when an input triggers setUser), { user, theme, setUser, setTheme } is a completely new object reference. React compares the old and new value with Object.is; unequal references trigger an update.

Second, after the update is triggered, every component using useContext(AppContext) rerenders -- whether it actually uses user or theme. So when you change a username, Profile rerenders (reasonable), but Header rerenders too (it only uses theme, which did not change, so that work is wasted). If there is also a component using the cart, it rerenders as well.

Why is React this "coarse-grained"? Because Context update checks happen during fiber tree traversal, and React cannot cheaply know which field of value a consumer actually used. Selective subscriptions require dependency tracking on the consumer side. That is what Zustand / use-context-selector do, not what Context itself does.

Remember this conclusion: Context updates are granular at the "Provider level," not the "field level." This determines every optimization strategy.

3. The Biggest Anti-Pattern: Putting All Global State into One Context

The most common pattern in real projects is putting everything that appears "global" into a single Context:

tsx
// Anti-pattern: God Context
const AppContext = createContext(null);

function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');
  const [cart, setCart] = useState([]);
  const [notifications, setNotifications] = useState([]);
  const [searchQuery, setSearchQuery] = useState('');
  // ...and a dozen more

  return (
    <AppContext.Provider
      value={{
        user, setUser, theme, setTheme, cart, setCart,
        notifications, setNotifications, searchQuery, setSearchQuery,
      }}
    >
      {children}
    </AppContext.Provider>
  );
}

This kind of "God Context" looks fine in a small demo, but becomes a performance bomb in a real product:

  • A user types one character into the search box -> searchQuery changes -> the entire value reference changes -> every consumer rerenders
  • One item is added to the cart -> cart changes -> the whole tree rerenders
  • A notification arrives -> notifications changes -> the whole tree rerenders

These states are often entirely unrelated: cart components do not care about searchQuery, and the notification center does not care about cart. Yet they are forced to move in lockstep.

There is a simple health check for this: open the React DevTools Profiler, record a normal interaction such as searching or adding an item to the cart, then inspect the Flamegraph. If each state change lights up a large set of components unrelated to that state, this is the issue.

4. Optimization One: Split Context by Concern

The most direct and cost-effective optimization is to split one God Context into several independent Contexts, with each Context responsible for only one concern.

tsx
// Good example: split by concern
const ThemeContext = createContext(null);
const UserContext = createContext(null);
const CartContext = createContext(null);

function AppProviders({ children }) {
  return (
    <ThemeProvider>
      <UserProvider>
        <CartProvider>{children}</CartProvider>
      </UserProvider>
    </ThemeProvider>
  );
}

After splitting, changing theme rerenders only components consuming ThemeContext; it does not affect components that only read user information. This delivers the greatest benefit for the smallest refactoring cost. In many cases, a single refactor eliminates more than half of the "large-scale rerenders of unrelated components" problem.

A useful rule of thumb for the splitting granularity is: split by "change frequency" and "usage scenario." Split high-frequency state separately (real-time data and search input); low-frequency state (theme, auth) can be grouped together. State consumed together by the same group of components can be combined, while state separately consumed by different components must be split.

5. Optimization Two: Stabilize the value Reference and Split State from Dispatch

After splitting Context, you still need to solve another common issue: unstable value references. Here is the standard Provider pattern:

tsx
// Good example: use useMemo to stabilize the value reference
function UserProvider({ children }) {
  const [user, setUser] = useState(null);

  const value = useMemo(() => ({ user, setUser }), [user]);
  // setUser is a stable reference returned by useState and does not need to be a dependency

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

The key point: setUser comes from useState, and its reference is stable throughout the component lifecycle, so it does not need to be included in the dependencies. This way, value receives a new reference only when user truly changes. Without useMemo, every Provider rerender creates a new object even if the rerender was triggered by a parent component, making the split pointless.

For useReducer scenarios, there is an especially effective pattern: put state and dispatch into two separate Contexts.

tsx
// Good example: split state and dispatch
const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);

function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  const stateValue = useMemo(() => state, [state]);
  // dispatch itself is a stable reference and can be passed through directly
  return (
    <CartStateContext.Provider value={stateValue}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

Why is this effective? Because many components only need to dispatch actions and do not need to read state. For example, an "Add to Cart" button only calls dispatch({ type: 'add' }); it does not care how many products are currently in the cart. If it is attached to CartDispatchContext, it will not rerender when cart contents change, because the dispatch reference never changes.

This principle is worth memorizing: components that read state rerendering when state changes is reasonable; components that only dispatch rerendering because state changed is pure waste.

6. Optimization Three: Selective Subscriptions, or Switch Directly to Zustand

At this point, we can solve most cases. But there is another class of problem that splitting Context cannot solve: within one Context's value, one component uses only one field while another field changes frequently.

A typical example is putting real-time cursor position and user settings into the same Context:

tsx
// Mouse coordinates change frequently, but ThemeToggle only wants to subscribe to theme
const MouseThemeContext = createContext({ x: 0, y: 0, theme: 'light' });

function ThemeToggle() {
  // Every mouse movement forces a rerender here, even though it only uses theme
  const theme = useContext(MouseThemeContext).theme;
  return <button>{theme}</button>;
}

The useContext API means you cannot subscribe to only theme: it returns the entire value, and any Context change triggers an update. To break through this limitation, the community solution is use-context-selector:

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

const MouseThemeContext = createContext({ x: 0, y: 0, theme: 'light' });

function ThemeToggle() {
  // Rerenders only when theme truly changes; x/y changes do not matter
  const theme = useContextSelector(MouseThemeContext, (v) => v.theme);
  return <button>{theme}</button>;
}

use-context-selector internally maintains publish-subscribe behavior and updates a component only when the selected slice changes. It works, but it is a polyfill-style library that replaces React's native Context, adds a small amount of cognitive overhead, and requires extra attention to compatibility under Concurrent Features.

At this point, I strongly recommend switching directly to Zustand. Zustand has selective subscriptions by design:

tsx
// Zustand has selective subscriptions by design
const useStore = create((set) => ({
  x: 0,
  y: 0,
  theme: 'light',
  setTheme: (theme) => set({ theme }),
}));

function ThemeToggle() {
  const theme = useStore((s) => s.theme);        // only subscribes to theme
  const setTheme = useStore((s) => s.setTheme);
  return <button onClick={() => setTheme('dark')}>{theme}</button>;
}

function Cursor() {
  const x = useStore((s) => s.x);
  const y = useStore((s) => s.y);
  return <div style={{ left: x, top: y }} />;
}

Zustand maintains state outside components (rather than on the fiber tree), and useStore(selector) triggers a rerender only when the selector's return value changes. This avoids Context's "broadcast" model at the root. It requires less code than use-context-selector, has a simpler mental model, and provides a more intuitive API.

7. When to Switch to Zustand

This question comes up constantly. Here is a clear criterion: look at update frequency.

ScenarioUpdate frequencyRecommendation
theme / i18n / auth / current signed-in userLow (minutes or slower)Context is enough
Form state and UI collapsible panelsMedium (driven by user interaction)Context + useMemo, or Zustand
Real-time data (SSE / WebSocket pushes), drag and drop, animation frames, search input, stock quotesHigh (multiple times per second)Zustand
High-frequency data shared across components + fine-grained subscriptions requiredHighZustand

The reason is simple: Context's broadcast cost is proportional to its number of consumers. In low-frequency scenarios, even if every consumer rerenders together, the total cost is negligible because updates are infrequent, so Context's simplicity shines. But once you enter high-frequency scenarios -- for example, a stock dashboard receiving 10 messages per second -- broadcast cost grows explosively in proportion to the number of consumers x update frequency, and Context's "broadcast" model becomes a performance bottleneck.

There are also several supporting criteria. If any one appears, seriously consider migrating:

  1. The Profiler shows a large set of "unrelated" components lighting up frequently, and splitting Context / optimizing with useMemo still cannot bring it under control.
  2. The state is meaningful outside the component tree (for example, it must be shared between multiple independent React roots or read and written from non-component code). Context cannot do this; Zustand can.
  3. You need middleware (persistence, logging, time-travel debugging). Context has no middleware concept, and building one yourself is less sensible than using an existing solution.
  4. State frequently needs to be derived. Zustand's selector + shallow equality makes subscribing to derived state inexpensive; Context + handwritten useMemo is easy to get wrong.

The reverse is also true: if your state is simply "application-level configuration + extremely low-frequency updates" (theme, locale, current signed-in user), do not adopt Zustand merely to "look advanced." Context plus a few lines of useMemo is completely sufficient, produces lighter code, has fewer dependencies, and is friendlier to the team. Technology choices are not about choosing the strongest option; they are about choosing the most suitable one.

8. The Decision Standard: Let the Profiler Decide

Every one of these optimizations should begin with measurement, not "I think it will be slow." The biggest pitfall in performance optimization is optimizing by intuition.

The concrete process:

  1. Open React DevTools, switch to the Profiler tab, and start recording.
  2. Perform the interaction you suspect is problematic (enter a search, add an item to the cart, receive a push, and so on), then stop recording.
  3. Inspect the Flamegraph and Ranked views. Darker colors in the Flamegraph represent longer render times; Ranked sorts by render cost from high to low.
  4. Focus on two signals: first, after an interaction, are many components unrelated to that interaction rendered (which means Context broadcasting is too broad)? Second, does a single component render far more often than expected (which means the value reference is unstable or subscription granularity is too coarse)?

DevTools also has a "Why did this render?" feature in a component's Props panel. It can directly tell you which Context changed for a render. This is the sharpest tool for diagnosing Context performance problems, ten times faster than guessing from the code.

One final point: Context itself is not a performance problem; misuse is. Context is doing its job when used for dependency injection and low-frequency global configuration, and it does that job well. It becomes a performance bomb only when it is treated as a general-purpose state management library and filled with fine-grained, high-frequency state. The problem is not the tool, but how it is used.

Summary

  1. Context is broadcast, not subscription: once the value reference changes, every consumer rerenders indiscriminately. Its granularity is Provider-level rather than field-level.
  2. The biggest anti-pattern is the "God Context": putting all global state into one Provider makes the whole component tree pay whenever any field changes. Split Context by concern and update frequency.
  3. Stabilizing the value reference is foundational: always use useMemo for Provider value; when using a reducer, split state and dispatch into two Contexts so dispatch-only components are not dragged into state changes.
  4. For fine-grained subscriptions, use either use-context-selector or Zustand: when you need to "subscribe to only one field," Context's native API is already insufficient. Rather than adding a polyfill, switch directly to Zustand.
  5. Choose based on update frequency: use Context for low-frequency state (theme / auth) and Zustand for high-frequency state (real-time data / drag and drop / animation). Let the Profiler provide the migration signal rather than relying on intuition.

Key takeaway: Context is dependency injection, not state management. Use it as dependency injection and it is a powerful tool; use it as state management and it is a performance bomb.

Related

Related Content