Back to Blog
ReactContextPerformanceState Management

React Context Optimization

Optimize React Context for performance. From splitting contexts to memoization to avoiding re-renders.

B
Bootspring Team
Engineering
December 11, 2020
7 min read

Context can cause unnecessary re-renders if not used carefully. Here's how to optimize it.

The Problem#

1// Bad: All consumers re-render when any value changes 2interface AppState { 3 user: User | null; 4 theme: 'light' | 'dark'; 5 notifications: Notification[]; 6 settings: Settings; 7} 8 9const AppContext = createContext<AppState>(null!); 10 11function AppProvider({ children }: { children: React.ReactNode }) { 12 const [state, setState] = useState<AppState>({ 13 user: null, 14 theme: 'light', 15 notifications: [], 16 settings: defaultSettings, 17 }); 18 19 // Every state change re-renders ALL consumers 20 return ( 21 <AppContext.Provider value={state}> 22 {children} 23 </AppContext.Provider> 24 ); 25} 26 27// UserProfile re-renders when notifications change! 28function UserProfile() { 29 const { user } = useContext(AppContext); 30 return <div>{user?.name}</div>; 31}

Split Contexts#

1// Good: Separate contexts for separate concerns 2const UserContext = createContext<User | null>(null); 3const ThemeContext = createContext<'light' | 'dark'>('light'); 4const NotificationsContext = createContext<Notification[]>([]); 5 6function AppProvider({ 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// Now UserProfile only re-renders when user changes 23function UserProfile() { 24 const user = useContext(UserContext); 25 return <div>{user?.name}</div>; 26} 27 28// Custom hooks for cleaner API 29function useUser() { 30 const context = useContext(UserContext); 31 if (context === undefined) { 32 throw new Error('useUser must be used within UserProvider'); 33 } 34 return context; 35}

Separate State and Dispatch#

1// Separate read and write contexts 2interface State { 3 count: number; 4 items: string[]; 5} 6 7type Action = 8 | { type: 'INCREMENT' } 9 | { type: 'ADD_ITEM'; payload: string }; 10 11const StateContext = createContext<State>(null!); 12const DispatchContext = createContext<React.Dispatch<Action>>(null!); 13 14function reducer(state: State, action: Action): State { 15 switch (action.type) { 16 case 'INCREMENT': 17 return { ...state, count: state.count + 1 }; 18 case 'ADD_ITEM': 19 return { ...state, items: [...state.items, action.payload] }; 20 default: 21 return state; 22 } 23} 24 25function Provider({ children }: { children: React.ReactNode }) { 26 const [state, dispatch] = useReducer(reducer, { 27 count: 0, 28 items: [], 29 }); 30 31 return ( 32 <StateContext.Provider value={state}> 33 <DispatchContext.Provider value={dispatch}> 34 {children} 35 </DispatchContext.Provider> 36 </StateContext.Provider> 37 ); 38} 39 40// Components that only dispatch don't re-render on state change 41function AddButton() { 42 const dispatch = useContext(DispatchContext); 43 44 return ( 45 <button onClick={() => dispatch({ type: 'INCREMENT' })}> 46 Add 47 </button> 48 ); 49} 50 51// Components that read state re-render when state changes 52function Counter() { 53 const { count } = useContext(StateContext); 54 return <span>{count}</span>; 55}

Memoize Provider Value#

1// Bad: New object on every render 2function BadProvider({ children }: { children: React.ReactNode }) { 3 const [user, setUser] = useState<User | null>(null); 4 5 // This creates a new object every render 6 return ( 7 <UserContext.Provider value={{ user, setUser }}> 8 {children} 9 </UserContext.Provider> 10 ); 11} 12 13// Good: Memoized value 14function GoodProvider({ children }: { children: React.ReactNode }) { 15 const [user, setUser] = useState<User | null>(null); 16 17 const value = useMemo( 18 () => ({ user, setUser }), 19 [user] 20 ); 21 22 return ( 23 <UserContext.Provider value={value}> 24 {children} 25 </UserContext.Provider> 26 ); 27} 28 29// Or split into state and actions 30function BetterProvider({ children }: { children: React.ReactNode }) { 31 const [user, setUser] = useState<User | null>(null); 32 33 // Actions never change 34 const actions = useMemo(() => ({ 35 login: (userData: User) => setUser(userData), 36 logout: () => setUser(null), 37 }), []); 38 39 return ( 40 <UserStateContext.Provider value={user}> 41 <UserActionsContext.Provider value={actions}> 42 {children} 43 </UserActionsContext.Provider> 44 </UserStateContext.Provider> 45 ); 46}

Selector Pattern#

1// Create a selector hook to pick specific values 2function useContextSelector<T, R>( 3 context: React.Context<T>, 4 selector: (value: T) => R 5): R { 6 const value = useContext(context); 7 const selectedRef = useRef(selector(value)); 8 const [, forceRender] = useReducer((x) => x + 1, 0); 9 10 useEffect(() => { 11 const newSelected = selector(value); 12 if (!Object.is(selectedRef.current, newSelected)) { 13 selectedRef.current = newSelected; 14 forceRender(); 15 } 16 }, [value, selector]); 17 18 return selectedRef.current; 19} 20 21// Or use external library: use-context-selector 22import { createContext, useContextSelector } from 'use-context-selector'; 23 24const AppContext = createContext<AppState>(null!); 25 26function UserName() { 27 // Only re-renders when user.name changes 28 const userName = useContextSelector( 29 AppContext, 30 (state) => state.user?.name 31 ); 32 33 return <span>{userName}</span>; 34} 35 36function NotificationCount() { 37 // Only re-renders when notifications length changes 38 const count = useContextSelector( 39 AppContext, 40 (state) => state.notifications.length 41 ); 42 43 return <span>{count}</span>; 44}

Compound Components#

1// Context for compound components 2interface TabsContextValue { 3 activeTab: string; 4 setActiveTab: (id: string) => void; 5} 6 7const TabsContext = createContext<TabsContextValue | null>(null); 8 9function Tabs({ children, defaultTab }: { 10 children: React.ReactNode; 11 defaultTab: string; 12}) { 13 const [activeTab, setActiveTab] = useState(defaultTab); 14 15 const value = useMemo( 16 () => ({ activeTab, setActiveTab }), 17 [activeTab] 18 ); 19 20 return ( 21 <TabsContext.Provider value={value}> 22 <div className="tabs">{children}</div> 23 </TabsContext.Provider> 24 ); 25} 26 27const TabButton = memo(function TabButton({ 28 id, 29 children, 30}: { 31 id: string; 32 children: React.ReactNode; 33}) { 34 const context = useContext(TabsContext); 35 if (!context) throw new Error('TabButton must be used within Tabs'); 36 37 const { activeTab, setActiveTab } = context; 38 const isActive = activeTab === id; 39 40 return ( 41 <button 42 className={isActive ? 'active' : ''} 43 onClick={() => setActiveTab(id)} 44 > 45 {children} 46 </button> 47 ); 48}); 49 50const TabPanel = memo(function TabPanel({ 51 id, 52 children, 53}: { 54 id: string; 55 children: React.ReactNode; 56}) { 57 const context = useContext(TabsContext); 58 if (!context) throw new Error('TabPanel must be used within Tabs'); 59 60 const { activeTab } = context; 61 62 if (activeTab !== id) return null; 63 64 return <div className="tab-panel">{children}</div>; 65}); 66 67Tabs.Button = TabButton; 68Tabs.Panel = TabPanel;

Preventing Child Re-renders#

1// Children re-render with provider 2function Parent() { 3 const [count, setCount] = useState(0); 4 5 return ( 6 <CountContext.Provider value={count}> 7 {/* These re-render when count changes */} 8 <ChildA /> 9 <ChildB /> 10 </CountContext.Provider> 11 ); 12} 13 14// Solution 1: Pass children as props 15function CountProvider({ children }: { children: React.ReactNode }) { 16 const [count, setCount] = useState(0); 17 18 return ( 19 <CountContext.Provider value={count}> 20 {children} 21 </CountContext.Provider> 22 ); 23} 24 25function App() { 26 return ( 27 <CountProvider> 28 {/* These don't re-render */} 29 <ChildA /> 30 <ChildB /> 31 </CountProvider> 32 ); 33} 34 35// Solution 2: Memoize children 36function Parent() { 37 const [count, setCount] = useState(0); 38 39 const children = useMemo(() => ( 40 <> 41 <ChildA /> 42 <ChildB /> 43 </> 44 ), []); 45 46 return ( 47 <CountContext.Provider value={count}> 48 {children} 49 </CountContext.Provider> 50 ); 51} 52 53// Solution 3: Use React.memo 54const ChildA = memo(function ChildA() { 55 // Only re-renders if props change 56 return <div>Child A</div>; 57});

Context with Immer#

1import { useImmerReducer } from 'use-immer'; 2 3interface State { 4 users: User[]; 5 selectedId: string | null; 6} 7 8type Action = 9 | { type: 'ADD_USER'; user: User } 10 | { type: 'UPDATE_USER'; id: string; updates: Partial<User> } 11 | { type: 'SELECT_USER'; id: string }; 12 13function reducer(draft: State, action: Action) { 14 switch (action.type) { 15 case 'ADD_USER': 16 draft.users.push(action.user); 17 break; 18 case 'UPDATE_USER': 19 const user = draft.users.find(u => u.id === action.id); 20 if (user) Object.assign(user, action.updates); 21 break; 22 case 'SELECT_USER': 23 draft.selectedId = action.id; 24 break; 25 } 26} 27 28function UsersProvider({ children }: { children: React.ReactNode }) { 29 const [state, dispatch] = useImmerReducer(reducer, { 30 users: [], 31 selectedId: null, 32 }); 33 34 return ( 35 <UsersStateContext.Provider value={state}> 36 <UsersDispatchContext.Provider value={dispatch}> 37 {children} 38 </UsersDispatchContext.Provider> 39 </UsersStateContext.Provider> 40 ); 41}

Testing Context#

1// Create a test wrapper 2function createTestWrapper(initialState?: Partial<AppState>) { 3 return function TestWrapper({ children }: { children: React.ReactNode }) { 4 return ( 5 <AppProvider initialState={{ ...defaultState, ...initialState }}> 6 {children} 7 </AppProvider> 8 ); 9 }; 10} 11 12// Use in tests 13import { renderHook } from '@testing-library/react'; 14 15test('useUser returns user', () => { 16 const wrapper = createTestWrapper({ 17 user: { id: '1', name: 'Alice' }, 18 }); 19 20 const { result } = renderHook(() => useUser(), { wrapper }); 21 22 expect(result.current.name).toBe('Alice'); 23});

Best Practices#

Structure: ✓ Split contexts by update frequency ✓ Separate state and dispatch ✓ Keep contexts focused ✓ Use custom hooks for access Performance: ✓ Memoize provider values ✓ Use selectors for granular access ✓ Prevent unnecessary re-renders ✓ Profile with React DevTools Patterns: ✓ Create context factories ✓ Use compound components ✓ Provide meaningful defaults ✓ Handle missing providers Organization: ✓ Co-locate context with components ✓ Export hooks, not contexts ✓ Document context shape ✓ Test providers separately

Conclusion#

Context optimization prevents unnecessary re-renders. Split contexts by concern, separate state from dispatch, memoize values, and use selectors for granular access. Profile with React DevTools to identify performance issues and apply these patterns strategically.

Share this article

Help spread the word about Bootspring