Back to Blog
ReactContextPerformanceOptimization

Optimizing React Context Performance

Avoid Context performance pitfalls. From splitting contexts to memoization to selector patterns.

B
Bootspring Team
Engineering
August 8, 2021
6 min read

React Context can cause unnecessary re-renders. Here's how to optimize it.

The Problem#

1// Every consumer re-renders when ANY value changes 2const AppContext = createContext({ 3 user: null, 4 theme: 'light', 5 notifications: [], 6 settings: {}, 7}); 8 9function AppProvider({ children }) { 10 const [user, setUser] = useState(null); 11 const [theme, setTheme] = useState('light'); 12 const [notifications, setNotifications] = useState([]); 13 const [settings, setSettings] = useState({}); 14 15 // New object every render! 16 const value = { 17 user, 18 setUser, 19 theme, 20 setTheme, 21 notifications, 22 setNotifications, 23 settings, 24 setSettings, 25 }; 26 27 return ( 28 <AppContext.Provider value={value}> 29 {children} 30 </AppContext.Provider> 31 ); 32} 33 34// This re-renders when notifications change, 35// even though it only uses theme! 36function ThemeButton() { 37 const { theme, setTheme } = useContext(AppContext); 38 return <button onClick={() => setTheme('dark')}>{theme}</button>; 39}

Solution 1: Split Contexts#

1// Separate contexts for unrelated data 2const UserContext = createContext<UserContextType | null>(null); 3const ThemeContext = createContext<ThemeContextType | null>(null); 4const NotificationContext = createContext<NotificationContextType | null>(null); 5 6// User provider 7function UserProvider({ children }) { 8 const [user, setUser] = useState(null); 9 10 const value = useMemo(() => ({ user, setUser }), [user]); 11 12 return ( 13 <UserContext.Provider value={value}> 14 {children} 15 </UserContext.Provider> 16 ); 17} 18 19// Theme provider 20function ThemeProvider({ children }) { 21 const [theme, setTheme] = useState('light'); 22 23 const value = useMemo(() => ({ theme, setTheme }), [theme]); 24 25 return ( 26 <ThemeContext.Provider value={value}> 27 {children} 28 </ThemeContext.Provider> 29 ); 30} 31 32// Compose providers 33function AppProviders({ children }) { 34 return ( 35 <UserProvider> 36 <ThemeProvider> 37 <NotificationProvider> 38 {children} 39 </NotificationProvider> 40 </ThemeProvider> 41 </UserProvider> 42 ); 43} 44 45// Now components only re-render for their specific context 46function ThemeButton() { 47 const { theme, setTheme } = useContext(ThemeContext); 48 // Only re-renders when theme changes 49 return <button onClick={() => setTheme('dark')}>{theme}</button>; 50}

Solution 2: Separate State and Dispatch#

1// State context (changes often) 2const StateContext = createContext<State | null>(null); 3 4// Dispatch context (never changes) 5const DispatchContext = createContext<Dispatch | null>(null); 6 7type State = { 8 count: number; 9 items: Item[]; 10}; 11 12type Action = 13 | { type: 'INCREMENT' } 14 | { type: 'ADD_ITEM'; item: Item }; 15 16function reducer(state: State, action: Action): State { 17 switch (action.type) { 18 case 'INCREMENT': 19 return { ...state, count: state.count + 1 }; 20 case 'ADD_ITEM': 21 return { ...state, items: [...state.items, action.item] }; 22 default: 23 return state; 24 } 25} 26 27function Provider({ children }) { 28 const [state, dispatch] = useReducer(reducer, { 29 count: 0, 30 items: [], 31 }); 32 33 return ( 34 <StateContext.Provider value={state}> 35 <DispatchContext.Provider value={dispatch}> 36 {children} 37 </DispatchContext.Provider> 38 </StateContext.Provider> 39 ); 40} 41 42// Components that only dispatch never re-render 43function AddButton() { 44 const dispatch = useContext(DispatchContext); 45 // Never re-renders due to state changes! 46 return ( 47 <button onClick={() => dispatch({ type: 'INCREMENT' })}> 48 Add 49 </button> 50 ); 51} 52 53// Components that read state re-render as needed 54function Counter() { 55 const state = useContext(StateContext); 56 return <div>Count: {state.count}</div>; 57}

Solution 3: Memoize Context Value#

1function UserProvider({ children }) { 2 const [user, setUser] = useState(null); 3 const [loading, setLoading] = useState(false); 4 5 // Memoize the value object 6 const value = useMemo( 7 () => ({ 8 user, 9 loading, 10 setUser, 11 setLoading, 12 }), 13 [user, loading] // Only setters are stable, states change 14 ); 15 16 return ( 17 <UserContext.Provider value={value}> 18 {children} 19 </UserContext.Provider> 20 ); 21} 22 23// Memoize callbacks separately 24function AuthProvider({ children }) { 25 const [user, setUser] = useState(null); 26 27 const login = useCallback(async (credentials) => { 28 const user = await authService.login(credentials); 29 setUser(user); 30 }, []); 31 32 const logout = useCallback(async () => { 33 await authService.logout(); 34 setUser(null); 35 }, []); 36 37 const value = useMemo( 38 () => ({ user, login, logout }), 39 [user, login, logout] 40 ); 41 42 return ( 43 <AuthContext.Provider value={value}> 44 {children} 45 </AuthContext.Provider> 46 ); 47}

Solution 4: Selector Pattern#

1// Create a selector hook 2function useContextSelector<T, S>( 3 context: React.Context<T>, 4 selector: (value: T) => S 5): S { 6 const contextValue = useContext(context); 7 const selectedValue = selector(contextValue); 8 9 // Use external library like use-context-selector 10 // for proper optimization 11 return selectedValue; 12} 13 14// Better: Use a library 15import { createContext, useContextSelector } from 'use-context-selector'; 16 17const AppContext = createContext<AppState | null>(null); 18 19function UserName() { 20 // Only re-renders when user.name changes 21 const userName = useContextSelector( 22 AppContext, 23 (state) => state?.user?.name 24 ); 25 26 return <span>{userName}</span>; 27} 28 29function NotificationCount() { 30 // Only re-renders when notifications.length changes 31 const count = useContextSelector( 32 AppContext, 33 (state) => state?.notifications.length 34 ); 35 36 return <span>{count}</span>; 37}

Solution 5: Component Composition#

1// Move state down - don't lift unnecessarily 2function App() { 3 return ( 4 <Layout> 5 <Sidebar /> 6 <Main /> 7 </Layout> 8 ); 9} 10 11// Bad: Lifting state that's only used in Sidebar 12function BadApp() { 13 const [sidebarState, setSidebarState] = useState(); 14 15 return ( 16 <AppContext.Provider value={{ sidebarState, setSidebarState }}> 17 <Layout> 18 <Sidebar /> 19 <Main /> {/* Re-renders unnecessarily */} 20 </Layout> 21 </AppContext.Provider> 22 ); 23} 24 25// Good: Keep state local 26function GoodApp() { 27 return ( 28 <Layout> 29 <Sidebar /> {/* Manages its own state */} 30 <Main /> 31 </Layout> 32 ); 33} 34 35// Pass components as children to avoid re-renders 36function ExpensiveProvider({ children }) { 37 const [state, setState] = useState(); 38 39 // children don't re-render when state changes 40 return ( 41 <Context.Provider value={{ state, setState }}> 42 {children} 43 </Context.Provider> 44 ); 45} 46 47// Usage 48<ExpensiveProvider> 49 <ExpensiveComponent /> {/* Doesn't re-render */} 50</ExpensiveProvider>

Solution 6: Ref for Non-Reactive State#

1function StoreProvider({ children }) { 2 const [state, setState] = useState(initialState); 3 4 // Ref doesn't trigger re-renders 5 const stateRef = useRef(state); 6 stateRef.current = state; 7 8 // Subscribers can read latest state without causing re-renders 9 const getState = useCallback(() => stateRef.current, []); 10 11 // For state that components need to subscribe to 12 const subscribe = useCallback((callback) => { 13 subscribers.current.add(callback); 14 return () => subscribers.current.delete(callback); 15 }, []); 16 17 const value = useMemo( 18 () => ({ state, setState, getState, subscribe }), 19 [state, getState, subscribe] 20 ); 21 22 return ( 23 <StoreContext.Provider value={value}> 24 {children} 25 </StoreContext.Provider> 26 ); 27} 28 29// External subscription pattern 30function useStore(selector) { 31 const { subscribe, getState } = useContext(StoreContext); 32 const [, forceRender] = useReducer((c) => c + 1, 0); 33 34 useEffect(() => { 35 return subscribe(() => { 36 forceRender(); 37 }); 38 }, [subscribe]); 39 40 return selector(getState()); 41}

Measuring Performance#

1import { Profiler } from 'react'; 2 3function onRenderCallback( 4 id, 5 phase, 6 actualDuration, 7 baseDuration, 8 startTime, 9 commitTime 10) { 11 console.log({ 12 id, 13 phase, 14 actualDuration, 15 baseDuration, 16 }); 17} 18 19<Profiler id="ThemeConsumer" onRender={onRenderCallback}> 20 <ThemeButton /> 21</Profiler> 22 23// Use React DevTools Profiler 24// - Record renders 25// - Identify unnecessary re-renders 26// - Check render times

Best Practices#

Structure: ✓ Split unrelated state into separate contexts ✓ Separate state from dispatch ✓ Keep contexts small and focused ✓ Memoize context values Optimization: ✓ Use useMemo for value objects ✓ Use useCallback for functions ✓ Consider selector libraries ✓ Profile before optimizing Patterns: ✓ Colocate state with usage ✓ Pass children to avoid re-renders ✓ Use refs for non-reactive state ✓ Compose multiple providers

Conclusion#

Context performance issues stem from unnecessary re-renders when any context value changes. Split contexts by domain, separate state from dispatch, memoize values, and consider selector patterns for granular subscriptions. Always profile to identify actual bottlenecks before optimizing.

Share this article

Help spread the word about Bootspring