ReactPerformanceHard

Optimizing React context performance

01

The Short Answer

React context re-renders every consumer whenever the context value changes — even if the specific piece of data a consumer uses hasn't changed. This makes context a performance bottleneck when used for frequently-updating state (like form inputs or animations) shared across many components. Optimizing context performance involves splitting contexts, memoizing values, using selectors, or reaching for external state managers when context isn't the right tool.

02

The Core Problem

When a context value changes, React re-renders every component that calls useContext() for that context — regardless of whether the specific property they read actually changed. There's no built-in selector mechanism. If your context holds { user, theme, notifications } and only notifications updates, components that only read user still re-render.

problem.tsxtypescript
// ❌ One big context — every consumer re-renders on ANY change
type AppState = {
  user: User | null;
  theme: 'light' | 'dark';
  notifications: Notification[];
  sidebarOpen: boolean;
};

const AppContext = createContext<AppState>(/* ... */);

// This component only reads `theme`, but re-renders when
// notifications change, sidebar toggles, user updates, etc.
function ThemeIndicator() {
  const { theme } = useContext(AppContext); // ← re-renders on ANY state change
  return <span>{theme === 'dark' ? '🌙' : '☀️'}</span>;
}

The ThemeIndicator component destructures only theme, but React doesn't know that — it sees that the context value object changed (new reference) and re-renders the consumer. This is the fundamental limitation: context triggers re-renders based on value identity, not on which properties are accessed.

03

Strategy 1: Split Contexts by Update Frequency

The most effective optimization is separating data that changes at different rates into different contexts. Theme rarely changes, user changes on login/logout, but notifications might update every few seconds. By splitting them, a notification update only re-renders notification consumers — not theme or user consumers.

split-contexts.tsxtypescript
// ✅ Split by update frequency — each context re-renders only its consumers
const UserContext = createContext<User | null>(null);
const ThemeContext = createContext<'light' | 'dark'>('light');
const NotificationContext = createContext<Notification[]>([]);

function Providers({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const [notifications, setNotifications] = useState<Notification[]>([]);

  return (
    <UserContext.Provider value={user}>
      <ThemeContext.Provider value={theme}>
        <NotificationContext.Provider value={notifications}>
          {children}
        </NotificationContext.Provider>
      </ThemeContext.Provider>
    </UserContext.Provider>
  );
}

// Now this only re-renders when theme actually changes
function ThemeIndicator() {
  const theme = useContext(ThemeContext);
  return <span>{theme === 'dark' ? '🌙' : '☀️'}</span>;
}

This is the recommended approach for most apps. The rule of thumb: if two pieces of state update at different frequencies or are consumed by different sets of components, they belong in separate contexts.

04

Strategy 2: Separate State from Dispatch

When using useReducer with context, split the state value and the dispatch function into separate contexts. The dispatch function is stable (same reference across renders), so components that only need to trigger actions — like buttons — never re-render due to state changes.

state-dispatch-split.tsxtypescript
type CountState = { count: number };
type CountDispatch = React.Dispatch<CountAction>;

const CountStateContext = createContext<CountState>({ count: 0 });
const CountDispatchContext = createContext<CountDispatch>(() => {});

function CountProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(countReducer, { count: 0 });

  return (
    <CountStateContext.Provider value={state}>
      <CountDispatchContext.Provider value={dispatch}>
        {children}
      </CountDispatchContext.Provider>
    </CountStateContext.Provider>
  );
}

// ✅ Only re-renders when count changes
function CountDisplay() {
  const { count } = useContext(CountStateContext);
  return <span>{count}</span>;
}

// ✅ Never re-renders due to count changes — dispatch is stable
function IncrementButton() {
  const dispatch = useContext(CountDispatchContext);
  return <button onClick={() => dispatch({ type: 'increment' })}>+1</button>;
}

The IncrementButton only consumes the dispatch context, which never changes reference. It won't re-render when the count updates. This pattern is especially valuable when you have many action-triggering components (buttons, links) that don't need to read state.

05

Strategy 3: Memoize the Context Value

If the provider component re-renders for reasons unrelated to the context value (parent re-render, other state changes), it creates a new object reference for the value prop — triggering all consumers to re-render even though the data hasn't changed. Wrapping the value in useMemo prevents this.

memoize-value.tsxtypescript
function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  // ❌ Without useMemo — new object every render, consumers always re-render
  // return <AuthContext.Provider value={{ user, loading, login, logout }}>

  const login = useCallback(async (credentials: Credentials) => {
    /* ... */
  }, []);

  const logout = useCallback(async () => {
    /* ... */
  }, []);

  // ✅ With useMemo — same reference unless user or loading actually changes
  const value = useMemo(
    () => ({ user, loading, login, logout }),
    [user, loading, login, logout]
  );

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

Memoizing the value only helps when the provider re-renders for reasons OTHER than the context data changing. If the context data itself changes, consumers will re-render regardless — that's correct behavior.

06

Strategy 4: Component Composition (Children Pattern)

If the provider's parent re-renders frequently, you can prevent the provider from re-rendering its children by accepting them as a children prop rather than rendering them inline. React skips re-rendering children if the prop reference hasn't changed — which it won't if the parent passes JSX defined at a higher level.

children-pattern.tsxtypescript
// ❌ Children rendered inline — re-render when Provider re-renders
function App() {
  return (
    <ThemeProvider>
      <Header />
      <Main />
      <Footer />
    </ThemeProvider>
  );
}

// The ThemeProvider component:
function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light');
  // When theme changes, children prop is the same reference
  // React knows it doesn't need to re-render Header/Main/Footer
  // Only components that useContext(ThemeContext) re-render
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
}

This works because children is created by the parent (App), not by ThemeProvider. When ThemeProvider re-renders due to state change, the children prop is the same JSX reference from App — React skips re-rendering those children. Only actual context consumers re-render.

07

When Context Isn't the Right Tool

Consider external state managers when

  • State updates very frequently (every keystroke, animation frames, mouse position)
  • Many components consume different slices of the same state object
  • You need selector-based subscriptions (only re-render when specific data changes)
  • You need computed/derived state that shouldn't trigger re-renders of unrelated consumers
  • Performance profiling shows context re-renders as a bottleneck

Libraries like Zustand, Jotai, and Redux Toolkit provide selector-based subscriptions — components only re-render when the specific slice they select changes. This is fundamentally more granular than context, which is all-or-nothing per context.

08

Why Interviewers Ask This

This is a hard-level question that tests production React experience. Interviewers want to see that you understand why context causes unnecessary re-renders (no selector mechanism), know multiple optimization strategies and when each applies, can identify when context is the wrong tool entirely, and have experience profiling and fixing performance issues in real apps. It separates developers who've built large apps from those who've only used context in tutorials.

Quick Revision Cheat Sheet

Core problem: Context re-renders ALL consumers when value changes — no selector support

Split contexts: Separate by update frequency — most effective optimization

State/dispatch split: Dispatch is stable — action-only consumers never re-render

useMemo value: Prevents re-renders from unrelated provider re-renders

Children pattern: Pass children as prop — React skips re-rendering them

When to use external: Frequent updates, many consumers, need selectors → Zustand/Jotai/Redux