Context Performance & Splitting

Understand why context re-renders consumers, and split it so fast-changing data does not drag the tree down.

Context is a delivery mechanism, not a performance feature. When a Provider's value changes by identity, every consumer of that context re-renders β€” even ones that only read a field that did not change. For low-frequency data (theme, locale, current user) this is fine. For anything that updates on every keystroke or animation frame, it can quietly cost you.

The two classic mistakes

  • A fresh object each render. value={{ user, setUser }} creates a new object every time the Provider renders, so consumers re-render even when nothing meaningful changed. Memoize it.
  • One mega-context. Bundling state and dispatch together means a component that only dispatches still re-renders when state changes.

Split state from dispatch

<StateCtx.Provider value={state}>
  <DispatchCtx.Provider value={dispatch}>
    {children}
  </DispatchCtx.Provider>
</StateCtx.Provider>

Now a button that only needs dispatch reads DispatchCtx. Since dispatch is stable, that button never re-renders from state changes. Only components reading StateCtx update when state moves.

When splitting is not enough

If many components each need a different slice of a fast-changing store, context is the wrong tool β€” reach for useSyncExternalStore or a store library (Zustand, Redux Toolkit) that lets each component subscribe to just its slice with a selector.

Example

Example Β· javascript
import { createContext, useContext, useReducer, useMemo } from 'react';

const CartStateContext = createContext(null);
const CartDispatchContext = createContext(null);

function cartReducer(state, action) {
  switch (action.type) {
    case 'add':
      return { ...state, count: state.count + 1 };
    case 'clear':
      return { ...state, count: 0 };
    default:
      return state;
  }
}

function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, { count: 0 });
  // state is a new object only when it truly changes;
  // dispatch is guaranteed stable by React.
  const stateValue = useMemo(() => state, [state]);
  return (
    <CartStateContext.Provider value={stateValue}>
      <CartDispatchContext.Provider value={dispatch}>
        {children}
      </CartDispatchContext.Provider>
    </CartStateContext.Provider>
  );
}

// Reads dispatch only -> never re-renders when count changes.
function AddButton() {
  const dispatch = useContext(CartDispatchContext);
  return <button onClick={() => dispatch({ type: 'add' })}>Add to cart</button>;
}

// Reads state -> re-renders when count changes (correctly).
function CartBadge() {
  const { count } = useContext(CartStateContext);
  return <span>{count}</span>;
}

When to use it

  • A design system splits theme tokens and the active-theme setter into two contexts so components that only read tokens do not re-render when the setter reference changes.
  • A collaboration tool moves cursor position out of the user context into a separate CursorContext so the 60 fps cursor updates do not re-render the entire authenticated-user subtree.
  • A dashboard separates slow-changing user preferences from fast-changing notification counts into two contexts so the preferences consumers never re-render during notification polling.

More examples

Split state and dispatch contexts

Separates state and dispatch into two contexts so components that only dispatch do not re-render on state changes.

Example Β· jsx
import { createContext, useContext, useReducer } from 'react';

const StateCtx = createContext(null);
const DispatchCtx = createContext(null);

export function StoreProvider({ reducer, initial, children }) {
  const [state, dispatch] = useReducer(reducer, initial);
  return (
    <DispatchCtx.Provider value={dispatch}>
      <StateCtx.Provider value={state}>
        {children}
      </StateCtx.Provider>
    </DispatchCtx.Provider>
  );
}

export const useStore = () => useContext(StateCtx);
export const useDispatch = () => useContext(DispatchCtx);

Stable value with useMemo

Wraps the context value in useMemo so a new object is only created when user changes, not on every parent render.

Example Β· jsx
import { createContext, useContext, useState, useMemo } from 'react';

const AuthCtx = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const value = useMemo(
    () => ({ user, login: setUser, logout: () => setUser(null) }),
    [user]
  );
  return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}

export const useAuth = () => useContext(AuthCtx);

Select a context slice to avoid re-renders

Destructures only the needed field from the context value; pair with use-context-selector to prevent non-field re-renders.

Example Β· jsx
import { createContext, useContext } from 'react';

const SettingsCtx = createContext({ theme: 'light', lang: 'en', fontSize: 14 });

// Only re-renders when theme changes, not when lang or fontSize change
function ThemeAwareButton({ children }) {
  const { theme } = useContext(SettingsCtx);
  return <button className={`btn btn--${theme}`}>{children}</button>;
}

// For more granular control, use a selector library like use-context-selector

Discussion

  • Be the first to comment on this lesson.