Context causes re-renders for all consumers on any change. Here's how to optimize with selectors.
The Problem#
1// Every context change re-renders all consumers
2interface AppState {
3 user: User | null;
4 theme: 'light' | 'dark';
5 notifications: Notification[];
6 settings: Settings;
7}
8
9const AppContext = createContext<AppState | null>(null);
10
11function App() {
12 const [state, setState] = useState<AppState>(initialState);
13
14 return (
15 <AppContext.Provider value={state}>
16 <Header /> {/* Re-renders on any state change */}
17 <Sidebar /> {/* Re-renders on any state change */}
18 <MainContent /> {/* Re-renders on any state change */}
19 </AppContext.Provider>
20 );
21}
22
23// Header only needs user, but re-renders when notifications change
24function Header() {
25 const state = useContext(AppContext);
26 return <div>Welcome, {state?.user?.name}</div>;
27}Solution 1: Split Contexts#
1// Separate contexts for different data
2const UserContext = createContext<User | null>(null);
3const ThemeContext = createContext<'light' | 'dark'>('light');
4const NotificationsContext = createContext<Notification[]>([]);
5
6function AppProviders({ children }: { children: React.ReactNode }) {
7 const [user, setUser] = useState<User | null>(null);
8 const [theme, setTheme] = useState<'light' | 'dark'>('light');
9 const [notifications, setNotifications] = useState<Notification[]>([]);
10
11 return (
12 <UserContext.Provider value={user}>
13 <ThemeContext.Provider value={theme}>
14 <NotificationsContext.Provider value={notifications}>
15 {children}
16 </NotificationsContext.Provider>
17 </ThemeContext.Provider>
18 </UserContext.Provider>
19 );
20}
21
22// Components only subscribe to what they need
23function Header() {
24 const user = useContext(UserContext);
25 // Only re-renders when user changes
26 return <div>Welcome, {user?.name}</div>;
27}
28
29function NotificationBadge() {
30 const notifications = useContext(NotificationsContext);
31 // Only re-renders when notifications change
32 return <span>{notifications.length}</span>;
33}Solution 2: Selector Hook with useSyncExternalStore#
1import { useSyncExternalStore } from 'react';
2
3// Store implementation
4type Listener = () => void;
5
6function createStore<T>(initialState: T) {
7 let state = initialState;
8 const listeners = new Set<Listener>();
9
10 return {
11 getState: () => state,
12 setState: (partial: Partial<T> | ((state: T) => Partial<T>)) => {
13 const nextPartial = typeof partial === 'function'
14 ? partial(state)
15 : partial;
16 state = { ...state, ...nextPartial };
17 listeners.forEach(listener => listener());
18 },
19 subscribe: (listener: Listener) => {
20 listeners.add(listener);
21 return () => listeners.delete(listener);
22 },
23 };
24}
25
26// Create store
27const store = createStore<AppState>(initialState);
28
29// Selector hook
30function useStoreSelector<T>(selector: (state: AppState) => T): T {
31 return useSyncExternalStore(
32 store.subscribe,
33 () => selector(store.getState()),
34 () => selector(store.getState())
35 );
36}
37
38// Usage - only re-renders when selected value changes
39function Header() {
40 const userName = useStoreSelector(state => state.user?.name);
41 return <div>Welcome, {userName}</div>;
42}
43
44function NotificationCount() {
45 const count = useStoreSelector(state => state.notifications.length);
46 return <span>{count}</span>;
47}Solution 3: Context with Ref and Subscription#
1import { createContext, useContext, useRef, useEffect, useState } from 'react';
2
3type Subscriber<T> = (state: T) => void;
4
5interface StoreContext<T> {
6 getState: () => T;
7 subscribe: (subscriber: Subscriber<T>) => () => void;
8 setState: (update: Partial<T> | ((state: T) => Partial<T>)) => void;
9}
10
11function createContextStore<T>(initialState: T) {
12 const Context = createContext<StoreContext<T> | null>(null);
13
14 function Provider({ children }: { children: React.ReactNode }) {
15 const stateRef = useRef(initialState);
16 const subscribersRef = useRef(new Set<Subscriber<T>>());
17
18 const store = useRef<StoreContext<T>>({
19 getState: () => stateRef.current,
20 subscribe: (subscriber) => {
21 subscribersRef.current.add(subscriber);
22 return () => subscribersRef.current.delete(subscriber);
23 },
24 setState: (update) => {
25 const nextPartial = typeof update === 'function'
26 ? update(stateRef.current)
27 : update;
28 stateRef.current = { ...stateRef.current, ...nextPartial };
29 subscribersRef.current.forEach(sub => sub(stateRef.current));
30 },
31 }).current;
32
33 return <Context.Provider value={store}>{children}</Context.Provider>;
34 }
35
36 function useSelector<R>(selector: (state: T) => R): R {
37 const store = useContext(Context);
38 if (!store) throw new Error('Provider required');
39
40 const [selected, setSelected] = useState(() =>
41 selector(store.getState())
42 );
43
44 useEffect(() => {
45 return store.subscribe((state) => {
46 const nextSelected = selector(state);
47 setSelected(prev => {
48 // Only update if value changed
49 return Object.is(prev, nextSelected) ? prev : nextSelected;
50 });
51 });
52 }, [store, selector]);
53
54 return selected;
55 }
56
57 function useStore() {
58 const store = useContext(Context);
59 if (!store) throw new Error('Provider required');
60 return store;
61 }
62
63 return { Provider, useSelector, useStore };
64}
65
66// Usage
67const { Provider, useSelector, useStore } = createContextStore<AppState>(initialState);
68
69function UserName() {
70 const name = useSelector(state => state.user?.name);
71 return <span>{name}</span>;
72}
73
74function ThemeToggle() {
75 const theme = useSelector(state => state.theme);
76 const store = useStore();
77
78 return (
79 <button onClick={() => store.setState({
80 theme: theme === 'light' ? 'dark' : 'light'
81 })}>
82 {theme}
83 </button>
84 );
85}Solution 4: Memoized Context Value#
1// Memoize context value to prevent reference changes
2function AppProvider({ children }: { children: React.ReactNode }) {
3 const [user, setUser] = useState<User | null>(null);
4 const [theme, setTheme] = useState<'light' | 'dark'>('light');
5
6 // Memoize the context value
7 const value = useMemo(() => ({
8 user,
9 theme,
10 setUser,
11 setTheme,
12 }), [user, theme]);
13
14 return (
15 <AppContext.Provider value={value}>
16 {children}
17 </AppContext.Provider>
18 );
19}
20
21// Memoize consumers that don't need all values
22const Header = memo(function Header() {
23 const { user } = useContext(AppContext);
24 return <div>Welcome, {user?.name}</div>;
25});Solution 5: Higher-Order Component Selector#
1function withSelector<T, P extends object>(
2 selector: (state: AppState) => T,
3 Component: React.ComponentType<P & { data: T }>
4) {
5 return function WrappedComponent(props: P) {
6 const data = useSelector(selector);
7 return <Component {...props} data={data} />;
8 };
9}
10
11// Usage
12interface HeaderProps {
13 data: { userName: string | undefined };
14}
15
16function HeaderBase({ data }: HeaderProps) {
17 return <div>Welcome, {data.userName}</div>;
18}
19
20const Header = withSelector(
21 state => ({ userName: state.user?.name }),
22 HeaderBase
23);Zustand-Style Implementation#
1import { useSyncExternalStore } from 'react';
2
3type SetState<T> = (
4 partial: Partial<T> | ((state: T) => Partial<T>)
5) => void;
6
7type GetState<T> = () => T;
8
9type StoreApi<T> = {
10 getState: GetState<T>;
11 setState: SetState<T>;
12 subscribe: (listener: () => void) => () => void;
13};
14
15function create<T>(
16 createState: (set: SetState<T>, get: GetState<T>) => T
17): StoreApi<T> & { useStore: <U>(selector: (state: T) => U) => U } {
18 let state: T;
19 const listeners = new Set<() => void>();
20
21 const getState: GetState<T> = () => state;
22
23 const setState: SetState<T> = (partial) => {
24 const nextPartial = typeof partial === 'function'
25 ? partial(state)
26 : partial;
27 state = { ...state, ...nextPartial };
28 listeners.forEach((listener) => listener());
29 };
30
31 const subscribe = (listener: () => void) => {
32 listeners.add(listener);
33 return () => listeners.delete(listener);
34 };
35
36 state = createState(setState, getState);
37
38 const useStore = <U,>(selector: (state: T) => U): U => {
39 return useSyncExternalStore(
40 subscribe,
41 () => selector(state),
42 () => selector(state)
43 );
44 };
45
46 return { getState, setState, subscribe, useStore };
47}
48
49// Usage
50const useAppStore = create<AppState>((set, get) => ({
51 user: null,
52 theme: 'light',
53 notifications: [],
54
55 login: async (credentials) => {
56 const user = await api.login(credentials);
57 set({ user });
58 },
59
60 toggleTheme: () => {
61 set({ theme: get().theme === 'light' ? 'dark' : 'light' });
62 },
63
64 addNotification: (notification) => {
65 set({ notifications: [...get().notifications, notification] });
66 },
67}));
68
69// Components with selectors
70function UserName() {
71 const name = useAppStore.useStore(state => state.user?.name);
72 return <span>{name}</span>;
73}
74
75function ThemeToggle() {
76 const theme = useAppStore.useStore(state => state.theme);
77 const toggleTheme = useAppStore.useStore(state => state.toggleTheme);
78
79 return <button onClick={toggleTheme}>{theme}</button>;
80}Performance Comparison#
1// Without selectors: All components re-render
2<AppContext.Provider value={{ user, theme, notifications }}>
3 <Header /> {/* Re-renders on any change */}
4 <NotificationList /> {/* Re-renders on any change */}
5 <ThemeToggle /> {/* Re-renders on any change */}
6</AppContext.Provider>
7
8// With selectors: Only affected components re-render
9function Header() {
10 const user = useSelector(state => state.user);
11 // Only re-renders when user changes
12}
13
14function NotificationCount() {
15 const count = useSelector(state => state.notifications.length);
16 // Only re-renders when count changes
17}Best Practices#
Strategies:
✓ Split contexts by update frequency
✓ Use selectors for granular subscriptions
✓ Memoize context values
✓ Consider external state libraries
Performance:
✓ Keep selectors simple
✓ Return primitive values when possible
✓ Use shallow comparison
✓ Avoid creating objects in selectors
Libraries:
✓ Zustand for simple state
✓ Jotai for atomic state
✓ use-context-selector for existing contexts
✓ React Query for server state
Conclusion#
Context selectors prevent unnecessary re-renders by subscribing to specific state slices. Split contexts for different domains, use useSyncExternalStore for selector patterns, or adopt libraries like Zustand. The right approach depends on your app's complexity and update patterns.