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.