Context Patterns

Hard

Context 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

Related Concepts