Back to Blog
ReactContextPerformanceSelectors

React Context Selectors Pattern

Optimize React Context with selectors. Prevent unnecessary re-renders and improve performance.

B
Bootspring Team
Engineering
March 25, 2021
7 min read

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.

Share this article

Help spread the word about Bootspring