Back to Blog
ReactContextStatePatterns

React Context Best Practices

Master React Context patterns. From provider design to consumption patterns to testing strategies.

B
Bootspring Team
Engineering
June 2, 2020
7 min read

React Context provides component-tree-wide state sharing. Here's how to use it effectively.

Creating Context#

1import { createContext, useContext, useState, ReactNode } from 'react'; 2 3// Define types 4interface User { 5 id: string; 6 name: string; 7 email: string; 8} 9 10interface AuthContextType { 11 user: User | null; 12 login: (email: string, password: string) => Promise<void>; 13 logout: () => void; 14 isLoading: boolean; 15} 16 17// Create context with default value 18const AuthContext = createContext<AuthContextType | undefined>(undefined); 19 20// Provider component 21function AuthProvider({ children }: { children: ReactNode }) { 22 const [user, setUser] = useState<User | null>(null); 23 const [isLoading, setIsLoading] = useState(false); 24 25 const login = async (email: string, password: string) => { 26 setIsLoading(true); 27 try { 28 const response = await api.login(email, password); 29 setUser(response.user); 30 } finally { 31 setIsLoading(false); 32 } 33 }; 34 35 const logout = () => { 36 setUser(null); 37 }; 38 39 const value = { user, login, logout, isLoading }; 40 41 return ( 42 <AuthContext.Provider value={value}> 43 {children} 44 </AuthContext.Provider> 45 ); 46} 47 48// Custom hook for consuming 49function useAuth() { 50 const context = useContext(AuthContext); 51 if (context === undefined) { 52 throw new Error('useAuth must be used within an AuthProvider'); 53 } 54 return context; 55} 56 57export { AuthProvider, useAuth };

Splitting State and Dispatch#

1// Separate contexts for better performance 2const StateContext = createContext<State | undefined>(undefined); 3const DispatchContext = createContext<Dispatch | undefined>(undefined); 4 5interface State { 6 count: number; 7 items: string[]; 8} 9 10type Action = 11 | { type: 'INCREMENT' } 12 | { type: 'DECREMENT' } 13 | { type: 'ADD_ITEM'; payload: string }; 14 15function reducer(state: State, action: Action): State { 16 switch (action.type) { 17 case 'INCREMENT': 18 return { ...state, count: state.count + 1 }; 19 case 'DECREMENT': 20 return { ...state, count: state.count - 1 }; 21 case 'ADD_ITEM': 22 return { ...state, items: [...state.items, action.payload] }; 23 default: 24 return state; 25 } 26} 27 28function AppProvider({ children }: { children: ReactNode }) { 29 const [state, dispatch] = useReducer(reducer, { count: 0, items: [] }); 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// Separate hooks 41function useAppState() { 42 const context = useContext(StateContext); 43 if (context === undefined) { 44 throw new Error('useAppState must be used within AppProvider'); 45 } 46 return context; 47} 48 49function useAppDispatch() { 50 const context = useContext(DispatchContext); 51 if (context === undefined) { 52 throw new Error('useAppDispatch must be used within AppProvider'); 53 } 54 return context; 55} 56 57// Components can subscribe to only what they need 58function Counter() { 59 const { count } = useAppState(); 60 return <span>{count}</span>; 61} 62 63function IncrementButton() { 64 const dispatch = useAppDispatch(); 65 return ( 66 <button onClick={() => dispatch({ type: 'INCREMENT' })}> 67 Increment 68 </button> 69 ); 70}

Memoizing Context Value#

1function AppProvider({ children }: { children: ReactNode }) { 2 const [user, setUser] = useState<User | null>(null); 3 const [theme, setTheme] = useState<'light' | 'dark'>('light'); 4 5 // Memoize callbacks 6 const login = useCallback(async (credentials: Credentials) => { 7 const user = await api.login(credentials); 8 setUser(user); 9 }, []); 10 11 const logout = useCallback(() => { 12 setUser(null); 13 }, []); 14 15 const toggleTheme = useCallback(() => { 16 setTheme(t => t === 'light' ? 'dark' : 'light'); 17 }, []); 18 19 // Memoize context value 20 const value = useMemo( 21 () => ({ 22 user, 23 theme, 24 login, 25 logout, 26 toggleTheme, 27 }), 28 [user, theme, login, logout, toggleTheme] 29 ); 30 31 return ( 32 <AppContext.Provider value={value}> 33 {children} 34 </AppContext.Provider> 35 ); 36}

Composing Multiple Providers#

1// Provider composition 2function AppProviders({ children }: { children: ReactNode }) { 3 return ( 4 <ErrorBoundary> 5 <AuthProvider> 6 <ThemeProvider> 7 <NotificationProvider> 8 <DataProvider> 9 {children} 10 </DataProvider> 11 </NotificationProvider> 12 </ThemeProvider> 13 </AuthProvider> 14 </ErrorBoundary> 15 ); 16} 17 18// Compose function helper 19function composeProviders(...providers: React.ComponentType<{ children: ReactNode }>[]) { 20 return ({ children }: { children: ReactNode }) => 21 providers.reduceRight( 22 (child, Provider) => <Provider>{child}</Provider>, 23 children 24 ); 25} 26 27const AppProviders = composeProviders( 28 ErrorBoundary, 29 AuthProvider, 30 ThemeProvider, 31 NotificationProvider, 32 DataProvider 33);

Context with Initialization#

1// Context that requires initialization 2interface Config { 3 apiUrl: string; 4 features: string[]; 5} 6 7const ConfigContext = createContext<Config | null>(null); 8 9function ConfigProvider({ 10 config, 11 children, 12}: { 13 config: Config; 14 children: ReactNode; 15}) { 16 return ( 17 <ConfigContext.Provider value={config}> 18 {children} 19 </ConfigContext.Provider> 20 ); 21} 22 23function useConfig() { 24 const config = useContext(ConfigContext); 25 if (config === null) { 26 throw new Error('useConfig must be used within ConfigProvider'); 27 } 28 return config; 29} 30 31// Usage 32function App() { 33 const [config, setConfig] = useState<Config | null>(null); 34 35 useEffect(() => { 36 loadConfig().then(setConfig); 37 }, []); 38 39 if (!config) { 40 return <Loading />; 41 } 42 43 return ( 44 <ConfigProvider config={config}> 45 <MainApp /> 46 </ConfigProvider> 47 ); 48}

Context with Selectors#

1// For fine-grained subscriptions 2import { useSyncExternalStore } from 'react'; 3 4function createStore<T>(initialState: T) { 5 let state = initialState; 6 const listeners = new Set<() => void>(); 7 8 return { 9 getState: () => state, 10 setState: (newState: T | ((prev: T) => T)) => { 11 state = typeof newState === 'function' 12 ? (newState as (prev: T) => T)(state) 13 : newState; 14 listeners.forEach(listener => listener()); 15 }, 16 subscribe: (listener: () => void) => { 17 listeners.add(listener); 18 return () => listeners.delete(listener); 19 }, 20 }; 21} 22 23type Store<T> = ReturnType<typeof createStore<T>>; 24 25const StoreContext = createContext<Store<AppState> | null>(null); 26 27function useSelector<T>(selector: (state: AppState) => T): T { 28 const store = useContext(StoreContext); 29 if (!store) throw new Error('Store not found'); 30 31 return useSyncExternalStore( 32 store.subscribe, 33 () => selector(store.getState()) 34 ); 35} 36 37// Only re-renders when selected value changes 38function UserName() { 39 const name = useSelector(state => state.user?.name); 40 return <span>{name}</span>; 41}

Testing Context#

1// Test wrapper 2function createTestWrapper(initialState?: Partial<AppState>) { 3 return function TestWrapper({ children }: { children: ReactNode }) { 4 return ( 5 <AppProvider initialState={initialState}> 6 {children} 7 </AppProvider> 8 ); 9 }; 10} 11 12// Test with custom render 13import { render, screen } from '@testing-library/react'; 14 15function renderWithProviders( 16 ui: React.ReactElement, 17 options?: { initialState?: Partial<AppState> } 18) { 19 const Wrapper = createTestWrapper(options?.initialState); 20 return render(ui, { wrapper: Wrapper }); 21} 22 23// Tests 24describe('UserProfile', () => { 25 it('displays user name', () => { 26 renderWithProviders(<UserProfile />, { 27 initialState: { user: { name: 'Alice' } }, 28 }); 29 30 expect(screen.getByText('Alice')).toBeInTheDocument(); 31 }); 32}); 33 34// Mock context for unit tests 35const mockAuthContext: AuthContextType = { 36 user: { id: '1', name: 'Test User', email: 'test@example.com' }, 37 login: jest.fn(), 38 logout: jest.fn(), 39 isLoading: false, 40}; 41 42function MockAuthProvider({ children }: { children: ReactNode }) { 43 return ( 44 <AuthContext.Provider value={mockAuthContext}> 45 {children} 46 </AuthContext.Provider> 47 ); 48}

Context with Persistence#

1// Persist context to localStorage 2function createPersistedContext<T>(key: string, defaultValue: T) { 3 const Context = createContext<{ 4 value: T; 5 setValue: (value: T) => void; 6 } | undefined>(undefined); 7 8 function Provider({ children }: { children: ReactNode }) { 9 const [value, setValue] = useState<T>(() => { 10 try { 11 const stored = localStorage.getItem(key); 12 return stored ? JSON.parse(stored) : defaultValue; 13 } catch { 14 return defaultValue; 15 } 16 }); 17 18 useEffect(() => { 19 localStorage.setItem(key, JSON.stringify(value)); 20 }, [value]); 21 22 return ( 23 <Context.Provider value={{ value, setValue }}> 24 {children} 25 </Context.Provider> 26 ); 27 } 28 29 function usePersistedContext() { 30 const context = useContext(Context); 31 if (context === undefined) { 32 throw new Error('usePersistedContext must be within Provider'); 33 } 34 return context; 35 } 36 37 return { Provider, usePersistedContext }; 38} 39 40// Usage 41const { Provider: ThemeProvider, usePersistedContext: useTheme } = 42 createPersistedContext('theme', 'light');

Common Pitfalls#

1// BAD: New object on every render 2function BadProvider({ children }) { 3 const [user, setUser] = useState(null); 4 5 return ( 6 <Context.Provider value={{ user, setUser }}> {/* New object each render */} 7 {children} 8 </Context.Provider> 9 ); 10} 11 12// GOOD: Memoize the value 13function GoodProvider({ children }) { 14 const [user, setUser] = useState(null); 15 const value = useMemo(() => ({ user, setUser }), [user]); 16 17 return ( 18 <Context.Provider value={value}> 19 {children} 20 </Context.Provider> 21 ); 22} 23 24// BAD: Using context for frequently changing values 25function BadFrequentUpdates() { 26 const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); 27 // Every mouse move re-renders all consumers! 28} 29 30// GOOD: Use refs or external store for frequent updates

Best Practices#

Design: ✓ Create custom hooks for consuming context ✓ Split state and dispatch contexts ✓ Memoize context values ✓ Use TypeScript for type safety Performance: ✓ Avoid frequent context updates ✓ Split contexts by update frequency ✓ Memoize callbacks in providers ✓ Consider selector patterns Testing: ✓ Create test wrappers ✓ Allow initial state injection ✓ Mock contexts for unit tests ✓ Test provider and consumer separately Avoid: ✗ Prop drilling alternatives only ✗ Context for high-frequency updates ✗ Too many nested providers ✗ Missing error boundaries

Conclusion#

React Context is powerful for sharing state across components. Split contexts by update frequency, memoize values and callbacks, and always create custom hooks for consuming context. For high-frequency updates or complex state, consider dedicated state management libraries.

Share this article

Help spread the word about Bootspring