Back to Blog
·4 min read

Why I stopped using React's useContext for component communication

useContext seems perfect for sharing state, but it causes unnecessary re-renders and makes component testing nearly impossible.

AI Dev
react
usecontext
context
performance

Why I stopped using React's useContext for component communication

I used to solve every component communication problem with useContext. It felt like the perfect React pattern -- create a context for shared state, wrap components in a provider, then access that state anywhere in the component tree. No more prop drilling, no more complex state lifting -- just clean, direct access to data from any component that needed it. Most React guides promoted this approach, and it worked beautifully for simple examples. Then I built a dashboard with real-time updates where multiple components needed access to user data, notifications, and application settings.

The breaking point came when I noticed my entire dashboard re-rendering every time a single notification arrived. I had created an AppContext that contained user info, notification count, theme settings, and current page state all in one object. When the notification count incremented from 3 to 4, every component consuming that context re-rendered -- even components that only cared about theme settings. I'd optimized individual components with React.memo, but context changes bypassed all those optimizations. When I needed to add a simple loading spinner that 50+ components could access, I realized my context was becoming a performance bottleneck.

That's when I discovered that context is not a state management solution -- it's a dependency injection mechanism. Context consumers re-render whenever the context value changes, regardless of which part of that value they actually use. There's no built-in way to subscribe to just a slice of context data.

The specific problems I encountered

Everything re-renders on any context change. Even with React.memo, components that consume context will re-render when any part of the context value updates. I couldn't prevent a navigation component from re-rendering when notification data changed.

Context values need referential stability. Every time my context provider re-rendered, I was creating a new object for the context value. This triggered re-renders in all consuming components, even when the actual data had not changed.

Testing becomes incredibly complex. Components that use context require elaborate test setup with mock providers. I could not test a simple button component without mocking an entire application context hierarchy.

No performance optimization options. Unlike state management libraries, React context provides no way to optimize subscriptions or prevent unnecessary updates. Every consumer gets every change.

What I use instead

I replaced most of my context usage with direct prop passing and custom hooks. For the majority of cases, the "prop drilling" I was trying to avoid was only 2-3 levels deep. The explicit data flow made components much easier to understand and test.

For complex shared state, I use Zustand or Valtio instead of context. These libraries provide fine-grained subscriptions where components only re-render when the specific data they consume actually changes:

// Instead of context
const useAppStore = create<AppState>((set) => ({
  user: null,
  notifications: [],
  theme: 'light',
  updateNotifications: (notifications) => set({ notifications }),
  setTheme: (theme) => set({ theme })
}))
 
// Component only re-renders when theme changes
function ThemeToggle() {
  const theme = useAppStore((state) => state.theme)
  const setTheme = useAppStore((state) => state.setTheme)
  
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      {theme} mode
    </button>
  )
}

For server state, I use TanStack Query which handles caching, background updates, and optimistic updates automatically. No context needed.

When I still use context

I reserve context for truly static configuration that changes rarely or never:

  • Theme configuration (not theme state -- just the theme object itself)
  • Feature flags that are set at application startup
  • Translation functions for internationalization
  • Router instances or other dependency injection

These values change infrequently and when they do change, it makes sense for the entire application to re-render.

// Good use of context - static configuration
const FeatureFlagsContext = createContext<FeatureFlags>({
  enableNewDashboard: false,
  showBetaFeatures: false
})
 
// Bad use of context - frequently changing state
const AppStateContext = createContext<AppState>({
  user: null,
  notifications: [],
  currentPage: 'home'
})

The key insight

Context is for dependency injection, not state management. When you find yourself updating context values frequently, or when you notice performance issues from excessive re-renders, that's a signal to use a dedicated state management library instead.

My applications are now faster, more testable, and easier to debug. Components have clear data dependencies, performance optimizations actually work, and I can test individual components without complex provider hierarchies. Context still has its place, but that place is much smaller than I originally thought.