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#
- Keep contexts focused - One context per domain (theme, auth, cart)
- Split state and actions - Prevent unnecessary re-renders
- Memoize values - Use useMemo for complex context values
- Handle SSR carefully - Watch for hydration mismatches
- Consider alternatives - Use Zustand for complex global state
Related Patterns#
- Zustand - Alternative state management
- React Query - Server state management
- URL State - URL-based state