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 timesBest 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.