20 minlesson

Context Patterns and Best Practices

Context Patterns and Best Practices

Now that you understand refs and context basics, let's explore advanced patterns for building scalable, maintainable context systems.

The Provider Pattern

Always wrap context in a dedicated provider component:

jsx
1import { createContext, useContext, useState, useMemo } from 'react';
2
3// 1. Create context with meaningful default
4const ThemeContext = createContext(null);
5
6// 2. Create provider component
7export function ThemeProvider({ children, defaultTheme = 'light' }) {
8 const [theme, setTheme] = useState(defaultTheme);
9
10 const toggleTheme = () => {
11 setTheme(t => t === 'light' ? 'dark' : 'light');
12 };
13
14 // Memoize to prevent unnecessary re-renders
15 const value = useMemo(() => ({
16 theme,
17 setTheme,
18 toggleTheme,
19 isDark: theme === 'dark'
20 }), [theme]);
21
22 return (
23 <ThemeContext.Provider value={value}>
24 {children}
25 </ThemeContext.Provider>
26 );
27}
28
29// 3. Create custom hook with error handling
30export function useTheme() {
31 const context = useContext(ThemeContext);
32
33 if (context === null) {
34 throw new Error('useTheme must be used within a ThemeProvider');
35 }
36
37 return context;
38}

Benefits:

  • Encapsulates state logic
  • Provides clear error messages
  • Single source of truth for the API

Separating State and Dispatch

For complex contexts, separate read and write operations:

jsx
1const StateContext = createContext(null);
2const DispatchContext = createContext(null);
3
4function CartProvider({ children }) {
5 const [state, dispatch] = useReducer(cartReducer, initialState);
6
7 return (
8 <StateContext.Provider value={state}>
9 <DispatchContext.Provider value={dispatch}>
10 {children}
11 </DispatchContext.Provider>
12 </StateContext.Provider>
13 );
14}
15
16// Components only re-render when their specific context changes
17function useCartState() {
18 const context = useContext(StateContext);
19 if (!context) throw new Error('useCartState must be within CartProvider');
20 return context;
21}
22
23function useCartDispatch() {
24 const context = useContext(DispatchContext);
25 if (!context) throw new Error('useCartDispatch must be within CartProvider');
26 return context;
27}
28
29// Combined hook for convenience
30function useCart() {
31 return [useCartState(), useCartDispatch()];
32}

Why separate? Components that only dispatch actions won't re-render when state changes.

Context with Reducer

Use useReducer for complex state logic:

jsx
1// Define action types
2const CartActions = {
3 ADD_ITEM: 'ADD_ITEM',
4 REMOVE_ITEM: 'REMOVE_ITEM',
5 UPDATE_QUANTITY: 'UPDATE_QUANTITY',
6 CLEAR_CART: 'CLEAR_CART'
7};
8
9// Reducer function
10function cartReducer(state, action) {
11 switch (action.type) {
12 case CartActions.ADD_ITEM: {
13 const existing = state.items.find(i => i.id === action.payload.id);
14 if (existing) {
15 return {
16 ...state,
17 items: state.items.map(item =>
18 item.id === action.payload.id
19 ? { ...item, quantity: item.quantity + 1 }
20 : item
21 )
22 };
23 }
24 return {
25 ...state,
26 items: [...state.items, { ...action.payload, quantity: 1 }]
27 };
28 }
29
30 case CartActions.REMOVE_ITEM:
31 return {
32 ...state,
33 items: state.items.filter(i => i.id !== action.payload)
34 };
35
36 case CartActions.UPDATE_QUANTITY:
37 return {
38 ...state,
39 items: state.items.map(item =>
40 item.id === action.payload.id
41 ? { ...item, quantity: action.payload.quantity }
42 : item
43 )
44 };
45
46 case CartActions.CLEAR_CART:
47 return { ...state, items: [] };
48
49 default:
50 throw new Error(`Unknown action: ${action.type}`);
51 }
52}
53
54// Provider with action creators
55function CartProvider({ children }) {
56 const [state, dispatch] = useReducer(cartReducer, { items: [] });
57
58 // Action creators for cleaner API
59 const actions = useMemo(() => ({
60 addItem: (item) => dispatch({ type: CartActions.ADD_ITEM, payload: item }),
61 removeItem: (id) => dispatch({ type: CartActions.REMOVE_ITEM, payload: id }),
62 updateQuantity: (id, quantity) =>
63 dispatch({ type: CartActions.UPDATE_QUANTITY, payload: { id, quantity } }),
64 clearCart: () => dispatch({ type: CartActions.CLEAR_CART })
65 }), []);
66
67 const value = useMemo(() => ({
68 ...state,
69 ...actions,
70 itemCount: state.items.reduce((sum, i) => sum + i.quantity, 0),
71 total: state.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
72 }), [state, actions]);
73
74 return (
75 <CartContext.Provider value={value}>
76 {children}
77 </CartContext.Provider>
78 );
79}

Persisting Context to localStorage

Sync context state with browser storage:

jsx
1function useLocalStorage(key, initialValue) {
2 const [storedValue, setStoredValue] = useState(() => {
3 try {
4 const item = window.localStorage.getItem(key);
5 return item ? JSON.parse(item) : initialValue;
6 } catch (error) {
7 console.error(`Error reading localStorage key "${key}":`, error);
8 return initialValue;
9 }
10 });
11
12 const setValue = (value) => {
13 try {
14 const valueToStore = value instanceof Function ? value(storedValue) : value;
15 setStoredValue(valueToStore);
16 window.localStorage.setItem(key, JSON.stringify(valueToStore));
17 } catch (error) {
18 console.error(`Error setting localStorage key "${key}":`, error);
19 }
20 };
21
22 return [storedValue, setValue];
23}
24
25// Theme provider with persistence
26function ThemeProvider({ children }) {
27 const [theme, setTheme] = useLocalStorage('theme', 'light');
28
29 // Sync with system preference
30 useEffect(() => {
31 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
32 const stored = localStorage.getItem('theme');
33
34 if (!stored) {
35 setTheme(mediaQuery.matches ? 'dark' : 'light');
36 }
37 }, []);
38
39 // Apply theme to document
40 useEffect(() => {
41 document.documentElement.setAttribute('data-theme', theme);
42 document.documentElement.classList.toggle('dark', theme === 'dark');
43 }, [theme]);
44
45 const value = useMemo(() => ({
46 theme,
47 setTheme,
48 toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
49 isDark: theme === 'dark'
50 }), [theme, setTheme]);
51
52 return (
53 <ThemeContext.Provider value={value}>
54 {children}
55 </ThemeContext.Provider>
56 );
57}

Compound Components with Context

Context enables powerful compound component patterns:

jsx
1const TabsContext = createContext(null);
2
3function Tabs({ children, defaultTab }) {
4 const [activeTab, setActiveTab] = useState(defaultTab);
5
6 return (
7 <TabsContext.Provider value={{ activeTab, setActiveTab }}>
8 <div className="tabs">{children}</div>
9 </TabsContext.Provider>
10 );
11}
12
13function TabList({ children }) {
14 return <div className="tab-list" role="tablist">{children}</div>;
15}
16
17function Tab({ id, children }) {
18 const { activeTab, setActiveTab } = useContext(TabsContext);
19 const isActive = activeTab === id;
20
21 return (
22 <button
23 role="tab"
24 aria-selected={isActive}
25 className={`tab ${isActive ? 'active' : ''}`}
26 onClick={() => setActiveTab(id)}
27 >
28 {children}
29 </button>
30 );
31}
32
33function TabPanels({ children }) {
34 return <div className="tab-panels">{children}</div>;
35}
36
37function TabPanel({ id, children }) {
38 const { activeTab } = useContext(TabsContext);
39
40 if (activeTab !== id) return null;
41
42 return (
43 <div role="tabpanel" className="tab-panel">
44 {children}
45 </div>
46 );
47}
48
49// Attach sub-components
50Tabs.List = TabList;
51Tabs.Tab = Tab;
52Tabs.Panels = TabPanels;
53Tabs.Panel = TabPanel;
54
55// Usage - Clean declarative API
56function App() {
57 return (
58 <Tabs defaultTab="overview">
59 <Tabs.List>
60 <Tabs.Tab id="overview">Overview</Tabs.Tab>
61 <Tabs.Tab id="features">Features</Tabs.Tab>
62 <Tabs.Tab id="pricing">Pricing</Tabs.Tab>
63 </Tabs.List>
64
65 <Tabs.Panels>
66 <Tabs.Panel id="overview">Overview content...</Tabs.Panel>
67 <Tabs.Panel id="features">Features content...</Tabs.Panel>
68 <Tabs.Panel id="pricing">Pricing content...</Tabs.Panel>
69 </Tabs.Panels>
70 </Tabs>
71 );
72}

Context Selectors Pattern

Optimize re-renders with selectors:

jsx
1import { createContext, useContext, useSyncExternalStore } from 'react';
2
3function createSelectableContext(initialValue) {
4 const Context = createContext(null);
5
6 function Provider({ children, value }) {
7 const storeRef = useRef(null);
8
9 if (!storeRef.current) {
10 let currentValue = value;
11 const listeners = new Set();
12
13 storeRef.current = {
14 getValue: () => currentValue,
15 subscribe: (listener) => {
16 listeners.add(listener);
17 return () => listeners.delete(listener);
18 },
19 update: (newValue) => {
20 currentValue = newValue;
21 listeners.forEach(l => l());
22 }
23 };
24 }
25
26 useEffect(() => {
27 storeRef.current.update(value);
28 }, [value]);
29
30 return (
31 <Context.Provider value={storeRef.current}>
32 {children}
33 </Context.Provider>
34 );
35 }
36
37 function useSelector(selector) {
38 const store = useContext(Context);
39
40 return useSyncExternalStore(
41 store.subscribe,
42 () => selector(store.getValue())
43 );
44 }
45
46 return { Provider, useSelector };
47}
48
49// Usage
50const { Provider: UserProvider, useSelector: useUserSelector } =
51 createSelectableContext(null);
52
53function UserName() {
54 // Only re-renders when name changes, not other user fields
55 const name = useUserSelector(user => user?.name);
56 return <span>{name}</span>;
57}
58
59function UserEmail() {
60 // Only re-renders when email changes
61 const email = useUserSelector(user => user?.email);
62 return <span>{email}</span>;
63}

Ref Patterns

Imperative Handle with useImperativeHandle

Expose custom methods from child to parent:

jsx
1import { useRef, useImperativeHandle } from 'react';
2
3function FancyInput({ ref }) {
4 const inputRef = useRef(null);
5
6 useImperativeHandle(ref, () => ({
7 focus: () => inputRef.current.focus(),
8 clear: () => { inputRef.current.value = ''; },
9 getValue: () => inputRef.current.value,
10 shake: () => {
11 inputRef.current.classList.add('shake');
12 setTimeout(() => inputRef.current.classList.remove('shake'), 500);
13 }
14 }));
15
16 return <input ref={inputRef} className="fancy-input" />;
17}
18
19// Parent controls child imperatively
20function Form() {
21 const inputRef = useRef(null);
22
23 const handleSubmit = () => {
24 const value = inputRef.current.getValue();
25 if (!value) {
26 inputRef.current.shake();
27 inputRef.current.focus();
28 return;
29 }
30 // Submit...
31 inputRef.current.clear();
32 };
33
34 return (
35 <form onSubmit={handleSubmit}>
36 <FancyInput ref={inputRef} />
37 <button type="submit">Submit</button>
38 </form>
39 );
40}

Ref Collections

Manage refs for dynamic lists:

jsx
1function ImageGallery({ images }) {
2 const itemRefs = useRef(new Map());
3
4 const scrollToImage = (id) => {
5 const node = itemRefs.current.get(id);
6 node?.scrollIntoView({ behavior: 'smooth', block: 'center' });
7 };
8
9 return (
10 <div>
11 <nav>
12 {images.map(img => (
13 <button key={img.id} onClick={() => scrollToImage(img.id)}>
14 {img.title}
15 </button>
16 ))}
17 </nav>
18
19 <div className="gallery">
20 {images.map(img => (
21 <img
22 key={img.id}
23 src={img.src}
24 alt={img.title}
25 ref={(node) => {
26 if (node) {
27 itemRefs.current.set(img.id, node);
28 } else {
29 itemRefs.current.delete(img.id);
30 }
31 }}
32 />
33 ))}
34 </div>
35 </div>
36 );
37}

Testing Context

Make contexts testable with wrapper utilities:

jsx
1// test-utils.jsx
2import { render } from '@testing-library/react';
3import { ThemeProvider } from './ThemeContext';
4import { AuthProvider } from './AuthContext';
5
6function AllProviders({ children }) {
7 return (
8 <ThemeProvider defaultTheme="light">
9 <AuthProvider>
10 {children}
11 </AuthProvider>
12 </ThemeProvider>
13 );
14}
15
16export function renderWithProviders(ui, options) {
17 return render(ui, { wrapper: AllProviders, ...options });
18}
19
20// In tests
21import { renderWithProviders } from './test-utils';
22
23test('displays user name', () => {
24 renderWithProviders(<UserGreeting />);
25 expect(screen.getByText(/welcome/i)).toBeInTheDocument();
26});

Summary

Best practices for context:

  1. Create custom hooks for each context with error handling
  2. Memoize context values to prevent unnecessary re-renders
  3. Separate state and dispatch for large contexts
  4. Use reducers for complex state logic
  5. Persist to localStorage when needed
  6. Consider compound components for related UI
  7. Use selectors for granular subscriptions
  8. Test with wrapper utilities

Next, let's build a complete theme system that applies all these patterns!