Context Patterns
HardContext patterns go beyond basic useContext to solve real-world problems: avoiding unnecessary re-renders, composing multiple providers, separating read and write contexts, and building type-safe context with custom hooks. These patterns form the foundation of scalable React state architecture.
Interactive Visualization
Key Points
- Split state and dispatch into separate contexts to avoid re-renders on dispatch-only consumers
- Compose providers with a single wrapper to avoid provider nesting
- Custom hooks with null checks provide type-safe context access
- Context selectors (via useSyncExternalStore) prevent re-renders from unrelated changes
- Module-level context factories create reusable context+provider+hook bundles
- Context is best for low-frequency state like theme, auth, and locale
Code Examples
Split State and Dispatch Contexts
import { createContext, useContext, useReducer, type ReactNode } from 'react' interface AppState { user: string | null theme: 'light' | 'dark' } type AppAction = | { type: 'LOGIN'; user: string } | { type: 'LOGOUT' } | { type: 'TOGGLE_THEME' } const StateContext = createContext<AppState | null>(null) const DispatchContext = createContext<React.Dispatch<AppAction> | null>(null) function appReducer(state: AppState, action: AppAction): AppState { switch (action.type) { case 'LOGIN': return { ...state, user: action.user } case 'LOGOUT': return { ...state, user: null } case 'TOGGLE_THEME': return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' } } } function AppProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(appReducer, { user: null, theme: 'light' }) return ( <StateContext.Provider value={state}> <DispatchContext.Provider value={dispatch}> {children} </DispatchContext.Provider> </StateContext.Provider> ) } function useAppState(): AppState { const ctx = useContext(StateContext) if (!ctx) throw new Error('useAppState must be within AppProvider') return ctx } function useAppDispatch(): React.Dispatch<AppAction> { const ctx = useContext(DispatchContext) if (!ctx) throw new Error('useAppDispatch must be within AppProvider') return ctx } // LogoutButton only needs dispatch — does not re-render when state changes function LogoutButton() { const dispatch = useAppDispatch() return <button onClick={() => dispatch({ type: 'LOGOUT' })}>Logout</button> }
Separating state and dispatch contexts means components that only dispatch actions never re-render when the state changes
Composing Multiple Providers
import { type ReactNode } from 'react' function ComposeProviders({ providers, children, }: { providers: Array<React.ComponentType<{ children: ReactNode }>> children: ReactNode }) { return providers.reduceRight( (acc, Provider) => <Provider>{acc}</Provider>, children ) } function App() { return ( <ComposeProviders providers={[ThemeProvider, AuthProvider, NotificationProvider]} > <MainContent /> </ComposeProviders> ) }
A provider compositor flattens deeply nested providers into a clean, readable array
Common Mistakes
- Putting all app state into a single context causing every consumer to re-render on any change
- Not memoizing context values, creating new object references on every provider render
- Using context for high-frequency state like animations or scroll position
Interview Tips
- Explain the split state/dispatch pattern and why it reduces unnecessary re-renders
- Discuss when to reach for a library like Zustand over raw context
- Know the tradeoffs: context is built-in but lacks selectors and middleware