React Context Patterns

Effective patterns for using React Context for state management.

Overview#

React Context is built into React and works well for certain use cases. This pattern covers:

  • Basic context setup
  • Context with reducer
  • Optimized context splitting
  • Context with localStorage
  • Composable providers

Basic Context#

A simple theme context with provider and hook.

1// contexts/ThemeContext.tsx 2'use client' 3 4import { createContext, useContext, useState, ReactNode } from 'react' 5 6type Theme = 'light' | 'dark' 7 8interface ThemeContextType { 9 theme: Theme 10 setTheme: (theme: Theme) => void 11 toggleTheme: () => void 12} 13 14const ThemeContext = createContext<ThemeContextType | undefined>(undefined) 15 16export function ThemeProvider({ children }: { children: ReactNode }) { 17 const [theme, setTheme] = useState<Theme>('light') 18 19 const toggleTheme = () => { 20 setTheme(prev => prev === 'light' ? 'dark' : 'light') 21 } 22 23 return ( 24 <ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}> 25 {children} 26 </ThemeContext.Provider> 27 ) 28} 29 30export function useTheme() { 31 const context = useContext(ThemeContext) 32 33 if (context === undefined) { 34 throw new Error('useTheme must be used within a ThemeProvider') 35 } 36 37 return context 38} 39 40// Usage 41function ThemeToggle() { 42 const { theme, toggleTheme } = useTheme() 43 44 return ( 45 <button onClick={toggleTheme}> 46 Current: {theme} 47 </button> 48 ) 49}

Context with Reducer#

Use useReducer for complex state logic.

1// contexts/CartContext.tsx 2'use client' 3 4import { createContext, useContext, useReducer, ReactNode, Dispatch } from 'react' 5 6interface CartItem { 7 id: string 8 name: string 9 price: number 10 quantity: number 11} 12 13interface CartState { 14 items: CartItem[] 15 total: number 16} 17 18type CartAction = 19 | { type: 'ADD_ITEM'; payload: Omit<CartItem, 'quantity'> } 20 | { type: 'REMOVE_ITEM'; payload: string } 21 | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } } 22 | { type: 'CLEAR_CART' } 23 24function cartReducer(state: CartState, action: CartAction): CartState { 25 switch (action.type) { 26 case 'ADD_ITEM': { 27 const existing = state.items.find(i => i.id === action.payload.id) 28 29 if (existing) { 30 const items = state.items.map(i => 31 i.id === action.payload.id 32 ? { ...i, quantity: i.quantity + 1 } 33 : i 34 ) 35 return { ...state, items, total: calculateTotal(items) } 36 } 37 38 const items = [...state.items, { ...action.payload, quantity: 1 }] 39 return { ...state, items, total: calculateTotal(items) } 40 } 41 42 case 'REMOVE_ITEM': { 43 const items = state.items.filter(i => i.id !== action.payload) 44 return { ...state, items, total: calculateTotal(items) } 45 } 46 47 case 'UPDATE_QUANTITY': { 48 const items = state.items 49 .map(i => i.id === action.payload.id 50 ? { ...i, quantity: action.payload.quantity } 51 : i 52 ) 53 .filter(i => i.quantity > 0) 54 return { ...state, items, total: calculateTotal(items) } 55 } 56 57 case 'CLEAR_CART': 58 return { items: [], total: 0 } 59 60 default: 61 return state 62 } 63} 64 65function calculateTotal(items: CartItem[]): number { 66 return items.reduce((sum, item) => sum + item.price * item.quantity, 0) 67} 68 69interface CartContextType { 70 state: CartState 71 dispatch: Dispatch<CartAction> 72 addItem: (item: Omit<CartItem, 'quantity'>) => void 73 removeItem: (id: string) => void 74 updateQuantity: (id: string, quantity: number) => void 75 clearCart: () => void 76} 77 78const CartContext = createContext<CartContextType | undefined>(undefined) 79 80export function CartProvider({ children }: { children: ReactNode }) { 81 const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 }) 82 83 const addItem = (item: Omit<CartItem, 'quantity'>) => 84 dispatch({ type: 'ADD_ITEM', payload: item }) 85 86 const removeItem = (id: string) => 87 dispatch({ type: 'REMOVE_ITEM', payload: id }) 88 89 const updateQuantity = (id: string, quantity: number) => 90 dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } }) 91 92 const clearCart = () => 93 dispatch({ type: 'CLEAR_CART' }) 94 95 return ( 96 <CartContext.Provider value={{ 97 state, dispatch, addItem, removeItem, updateQuantity, clearCart 98 }}> 99 {children} 100 </CartContext.Provider> 101 ) 102} 103 104export function useCart() { 105 const context = useContext(CartContext) 106 if (!context) { 107 throw new Error('useCart must be used within a CartProvider') 108 } 109 return context 110}

Optimized Context#

Split state and actions to prevent unnecessary re-renders.

1// contexts/OptimizedUserContext.tsx 2import { createContext, useContext, useState, useMemo, ReactNode } from 'react' 3 4interface User { 5 id: string 6 name: string 7 email: string 8} 9 10// Separate contexts for state and actions 11const UserStateContext = createContext<User | null>(null) 12const UserActionsContext = createContext<{ 13 setUser: (user: User | null) => void 14 updateUser: (updates: Partial<User>) => void 15} | null>(null) 16 17export function UserProvider({ children }: { children: ReactNode }) { 18 const [user, setUser] = useState<User | null>(null) 19 20 // Memoize actions to prevent re-renders 21 const actions = useMemo(() => ({ 22 setUser, 23 updateUser: (updates: Partial<User>) => 24 setUser(prev => prev ? { ...prev, ...updates } : null) 25 }), []) 26 27 return ( 28 <UserStateContext.Provider value={user}> 29 <UserActionsContext.Provider value={actions}> 30 {children} 31 </UserActionsContext.Provider> 32 </UserStateContext.Provider> 33 ) 34} 35 36// Separate hooks for state and actions 37export function useUser() { 38 return useContext(UserStateContext) 39} 40 41export function useUserActions() { 42 const context = useContext(UserActionsContext) 43 if (!context) { 44 throw new Error('useUserActions must be used within UserProvider') 45 } 46 return context 47} 48 49// Components that only read user won't re-render when actions change 50function UserDisplay() { 51 const user = useUser() 52 return <span>{user?.name}</span> 53} 54 55// Components that only use actions won't re-render when user changes 56function LogoutButton() { 57 const { setUser } = useUserActions() 58 return <button onClick={() => setUser(null)}>Logout</button> 59}

Composable Providers#

Combine multiple providers cleanly.

1// app/providers.tsx 2'use client' 3 4import { ThemeProvider } from '@/contexts/ThemeContext' 5import { UserProvider } from '@/contexts/UserContext' 6import { CartProvider } from '@/contexts/CartContext' 7import { QueryProvider } from '@/contexts/QueryContext' 8import { ReactNode } from 'react' 9 10type Provider = ({ children }: { children: ReactNode }) => JSX.Element 11 12function composeProviders(...providers: Provider[]) { 13 return ({ children }: { children: ReactNode }) => 14 providers.reduceRight( 15 (acc, Provider) => <Provider>{acc}</Provider>, 16 children 17 ) 18} 19 20const Providers = composeProviders( 21 QueryProvider, 22 ThemeProvider, 23 UserProvider, 24 CartProvider 25) 26 27export default Providers 28 29// app/layout.tsx 30import Providers from './providers' 31 32export default function RootLayout({ children }) { 33 return ( 34 <html> 35 <body> 36 <Providers>{children}</Providers> 37 </body> 38 </html> 39 ) 40}

Context with localStorage#

Persist context state to localStorage.

1// contexts/SettingsContext.tsx 2'use client' 3 4import { createContext, useContext, useEffect, useState, ReactNode } from 'react' 5 6interface Settings { 7 notifications: boolean 8 language: string 9} 10 11const defaultSettings: Settings = { 12 notifications: true, 13 language: 'en' 14} 15 16const SettingsContext = createContext<{ 17 settings: Settings 18 updateSettings: (updates: Partial<Settings>) => void 19} | null>(null) 20 21export function SettingsProvider({ children }: { children: ReactNode }) { 22 const [settings, setSettings] = useState<Settings>(defaultSettings) 23 const [mounted, setMounted] = useState(false) 24 25 // Load from localStorage on mount 26 useEffect(() => { 27 const stored = localStorage.getItem('settings') 28 if (stored) { 29 setSettings(JSON.parse(stored)) 30 } 31 setMounted(true) 32 }, []) 33 34 // Persist to localStorage on change 35 useEffect(() => { 36 if (mounted) { 37 localStorage.setItem('settings', JSON.stringify(settings)) 38 } 39 }, [settings, mounted]) 40 41 const updateSettings = (updates: Partial<Settings>) => { 42 setSettings(prev => ({ ...prev, ...updates })) 43 } 44 45 // Prevent hydration mismatch 46 if (!mounted) { 47 return null 48 } 49 50 return ( 51 <SettingsContext.Provider value={{ settings, updateSettings }}> 52 {children} 53 </SettingsContext.Provider> 54 ) 55} 56 57export function useSettings() { 58 const context = useContext(SettingsContext) 59 if (!context) { 60 throw new Error('useSettings must be used within SettingsProvider') 61 } 62 return context 63}

Best Practices#

  1. Keep contexts focused - One context per domain (theme, auth, cart)
  2. Split state and actions - Prevent unnecessary re-renders
  3. Memoize values - Use useMemo for complex context values
  4. Handle SSR carefully - Watch for hydration mismatches
  5. Consider alternatives - Use Zustand for complex global state