React Context without the performance problems — three patterns that work
← Back
April 4, 2026React6 min read

React Context without the performance problems — three patterns that work

Published April 4, 20266 min read

I put user preferences (theme, language, notification settings) into a single Context and noticed my entire app re-rendering every time the user changed one preference. The problem: every context consumer re-renders when the context value changes — even if the specific field they use did not change. Here are three patterns that solve this.

Pattern 1: Split contexts by update frequency

The most effective fix: separate frequently-changing values from stable values:

typescript
// BAD: One context — changing any field re-renders all consumers
const AppContext = createContext({
  user: null,          // Set once on login
  theme: 'light',      // Changes when user toggles
  cartCount: 0,        // Changes on every cart update
});

// GOOD: Three contexts by update frequency
const UserContext = createContext(null);        // Stable
const ThemeContext = createContext('light');           // Rare changes
const CartContext = createContext<{ count: number }>({ count: 0 }); // Frequent

// Components only subscribe to what they need
function Navbar() {
  const user = useContext(UserContext);  // Only re-renders on user change
  const cart = useContext(CartContext);  // Re-renders on every cart update
  
  // ThemeContext changes do NOT cause Navbar to re-render
  return ;
}

Pattern 2: Separate state and dispatch

Put the updater functions in a separate context. Updater functions are stable (created once), so components that only call them never re-render due to state changes:

typescript
interface CartState {
  items: CartItem[];
  total: number;
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'CLEAR' };

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

export function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });

  return (
    
      
        {children}
      
    
  );
}

// AddToCartButton: subscribes to dispatch only — never re-renders due to cart state changes
function AddToCartButton({ item }: { item: Product }) {
  const dispatch = useContext(CartDispatchContext)!;
  return (
    
  );
}

// CartBadge: subscribes to state — re-renders when cart changes (it should)
function CartBadge() {
  const { items } = useContext(CartStateContext)!;
  return {items.length};
}

Pattern 3: Selector pattern with useSyncExternalStore

For fine-grained subscriptions (re-render only when a specific field changes), use a store with useSyncExternalStore:

typescript
// lib/store.ts — simple observable store
type Listener = () => void;

function createStore(initialState: T) {
  let state = initialState;
  const listeners = new Set();

  return {
    getState: () => state,
    setState: (updater: (prev: T) => T) => {
      state = updater(state);
      listeners.forEach((l) => l());
    },
    subscribe: (listener: Listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

const userStore = createStore({
  name: 'Alice',
  theme: 'light' as 'light' | 'dark',
  language: 'en',
  notificationsEnabled: true,
});

// Hook with selector — only re-renders when selected value changes
function useUserStore(selector: (state: typeof userStore extends ReturnType> ? S : never) => T): T {
  return useSyncExternalStore(
    userStore.subscribe,
    () => selector(userStore.getState()),
  );
}

// Usage:
function ThemeToggle() {
  // Only re-renders when theme changes, not name/language/etc.
  const theme = useUserStore((s) => s.theme);
  return (
    
  );
}

function UserName() {
  // Only re-renders when name changes
  const name = useUserStore((s) => s.name);
  return {name};
}

When to use each pattern

  • Split contexts: simplest, best for most cases, use it by default
  • State/dispatch split: when you have many "write-only" components that do not need to read state
  • useSyncExternalStore: when you need selector-level granularity without Zustand or Jotai

If you find yourself reaching for Zustand or Jotai primarily for performance, try split contexts first. It often solves 90% of the problem with zero dependencies.

Share this
← All Posts6 min read