React Context without the performance problems — three patterns that work
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:
// 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:
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:
// 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.