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:
jsx1import { createContext, useContext, useState, useMemo } from 'react';23// 1. Create context with meaningful default4const ThemeContext = createContext(null);56// 2. Create provider component7export function ThemeProvider({ children, defaultTheme = 'light' }) {8 const [theme, setTheme] = useState(defaultTheme);910 const toggleTheme = () => {11 setTheme(t => t === 'light' ? 'dark' : 'light');12 };1314 // Memoize to prevent unnecessary re-renders15 const value = useMemo(() => ({16 theme,17 setTheme,18 toggleTheme,19 isDark: theme === 'dark'20 }), [theme]);2122 return (23 <ThemeContext.Provider value={value}>24 {children}25 </ThemeContext.Provider>26 );27}2829// 3. Create custom hook with error handling30export function useTheme() {31 const context = useContext(ThemeContext);3233 if (context === null) {34 throw new Error('useTheme must be used within a ThemeProvider');35 }3637 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:
jsx1const StateContext = createContext(null);2const DispatchContext = createContext(null);34function CartProvider({ children }) {5 const [state, dispatch] = useReducer(cartReducer, initialState);67 return (8 <StateContext.Provider value={state}>9 <DispatchContext.Provider value={dispatch}>10 {children}11 </DispatchContext.Provider>12 </StateContext.Provider>13 );14}1516// Components only re-render when their specific context changes17function useCartState() {18 const context = useContext(StateContext);19 if (!context) throw new Error('useCartState must be within CartProvider');20 return context;21}2223function useCartDispatch() {24 const context = useContext(DispatchContext);25 if (!context) throw new Error('useCartDispatch must be within CartProvider');26 return context;27}2829// Combined hook for convenience30function 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:
jsx1// Define action types2const CartActions = {3 ADD_ITEM: 'ADD_ITEM',4 REMOVE_ITEM: 'REMOVE_ITEM',5 UPDATE_QUANTITY: 'UPDATE_QUANTITY',6 CLEAR_CART: 'CLEAR_CART'7};89// Reducer function10function 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.id19 ? { ...item, quantity: item.quantity + 1 }20 : item21 )22 };23 }24 return {25 ...state,26 items: [...state.items, { ...action.payload, quantity: 1 }]27 };28 }2930 case CartActions.REMOVE_ITEM:31 return {32 ...state,33 items: state.items.filter(i => i.id !== action.payload)34 };3536 case CartActions.UPDATE_QUANTITY:37 return {38 ...state,39 items: state.items.map(item =>40 item.id === action.payload.id41 ? { ...item, quantity: action.payload.quantity }42 : item43 )44 };4546 case CartActions.CLEAR_CART:47 return { ...state, items: [] };4849 default:50 throw new Error(`Unknown action: ${action.type}`);51 }52}5354// Provider with action creators55function CartProvider({ children }) {56 const [state, dispatch] = useReducer(cartReducer, { items: [] });5758 // Action creators for cleaner API59 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 }), []);6667 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]);7374 return (75 <CartContext.Provider value={value}>76 {children}77 </CartContext.Provider>78 );79}
Persisting Context to localStorage
Sync context state with browser storage:
jsx1function 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 });1112 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 };2122 return [storedValue, setValue];23}2425// Theme provider with persistence26function ThemeProvider({ children }) {27 const [theme, setTheme] = useLocalStorage('theme', 'light');2829 // Sync with system preference30 useEffect(() => {31 const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');32 const stored = localStorage.getItem('theme');3334 if (!stored) {35 setTheme(mediaQuery.matches ? 'dark' : 'light');36 }37 }, []);3839 // Apply theme to document40 useEffect(() => {41 document.documentElement.setAttribute('data-theme', theme);42 document.documentElement.classList.toggle('dark', theme === 'dark');43 }, [theme]);4445 const value = useMemo(() => ({46 theme,47 setTheme,48 toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),49 isDark: theme === 'dark'50 }), [theme, setTheme]);5152 return (53 <ThemeContext.Provider value={value}>54 {children}55 </ThemeContext.Provider>56 );57}
Compound Components with Context
Context enables powerful compound component patterns:
jsx1const TabsContext = createContext(null);23function Tabs({ children, defaultTab }) {4 const [activeTab, setActiveTab] = useState(defaultTab);56 return (7 <TabsContext.Provider value={{ activeTab, setActiveTab }}>8 <div className="tabs">{children}</div>9 </TabsContext.Provider>10 );11}1213function TabList({ children }) {14 return <div className="tab-list" role="tablist">{children}</div>;15}1617function Tab({ id, children }) {18 const { activeTab, setActiveTab } = useContext(TabsContext);19 const isActive = activeTab === id;2021 return (22 <button23 role="tab"24 aria-selected={isActive}25 className={`tab ${isActive ? 'active' : ''}`}26 onClick={() => setActiveTab(id)}27 >28 {children}29 </button>30 );31}3233function TabPanels({ children }) {34 return <div className="tab-panels">{children}</div>;35}3637function TabPanel({ id, children }) {38 const { activeTab } = useContext(TabsContext);3940 if (activeTab !== id) return null;4142 return (43 <div role="tabpanel" className="tab-panel">44 {children}45 </div>46 );47}4849// Attach sub-components50Tabs.List = TabList;51Tabs.Tab = Tab;52Tabs.Panels = TabPanels;53Tabs.Panel = TabPanel;5455// Usage - Clean declarative API56function 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>6465 <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:
jsx1import { createContext, useContext, useSyncExternalStore } from 'react';23function createSelectableContext(initialValue) {4 const Context = createContext(null);56 function Provider({ children, value }) {7 const storeRef = useRef(null);89 if (!storeRef.current) {10 let currentValue = value;11 const listeners = new Set();1213 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 }2526 useEffect(() => {27 storeRef.current.update(value);28 }, [value]);2930 return (31 <Context.Provider value={storeRef.current}>32 {children}33 </Context.Provider>34 );35 }3637 function useSelector(selector) {38 const store = useContext(Context);3940 return useSyncExternalStore(41 store.subscribe,42 () => selector(store.getValue())43 );44 }4546 return { Provider, useSelector };47}4849// Usage50const { Provider: UserProvider, useSelector: useUserSelector } =51 createSelectableContext(null);5253function UserName() {54 // Only re-renders when name changes, not other user fields55 const name = useUserSelector(user => user?.name);56 return <span>{name}</span>;57}5859function UserEmail() {60 // Only re-renders when email changes61 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:
jsx1import { useRef, useImperativeHandle } from 'react';23function FancyInput({ ref }) {4 const inputRef = useRef(null);56 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 }));1516 return <input ref={inputRef} className="fancy-input" />;17}1819// Parent controls child imperatively20function Form() {21 const inputRef = useRef(null);2223 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 };3334 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:
jsx1function ImageGallery({ images }) {2 const itemRefs = useRef(new Map());34 const scrollToImage = (id) => {5 const node = itemRefs.current.get(id);6 node?.scrollIntoView({ behavior: 'smooth', block: 'center' });7 };89 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>1819 <div className="gallery">20 {images.map(img => (21 <img22 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:
jsx1// test-utils.jsx2import { render } from '@testing-library/react';3import { ThemeProvider } from './ThemeContext';4import { AuthProvider } from './AuthContext';56function AllProviders({ children }) {7 return (8 <ThemeProvider defaultTheme="light">9 <AuthProvider>10 {children}11 </AuthProvider>12 </ThemeProvider>13 );14}1516export function renderWithProviders(ui, options) {17 return render(ui, { wrapper: AllProviders, ...options });18}1920// In tests21import { renderWithProviders } from './test-utils';2223test('displays user name', () => {24 renderWithProviders(<UserGreeting />);25 expect(screen.getByText(/welcome/i)).toBeInTheDocument();26});
Summary
Best practices for context:
- Create custom hooks for each context with error handling
- Memoize context values to prevent unnecessary re-renders
- Separate state and dispatch for large contexts
- Use reducers for complex state logic
- Persist to localStorage when needed
- Consider compound components for related UI
- Use selectors for granular subscriptions
- Test with wrapper utilities
Next, let's build a complete theme system that applies all these patterns!