DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

React Context vs Zustand: When Context Is Enough (and When It Isn't) (2026)

The most common mistake with React state management isn't reaching for Zustand too early — it's reaching for Context incorrectly, then blaming Context for problems that come from misusing it.

The Re-Render Problem

Every component that calls useContext(SomeContext) re-renders whenever the context value changes — even if the part that changed is irrelevant to it.

interface AppState {
  user: User | null
  sidebarOpen: boolean
  notifications: Notification[]
}

const AppContext = createContext<AppState>(/* ... */)

function Navbar() {
  const { user } = useContext(AppContext) // subscribes to EVERYTHING
  return <nav>{user?.name}</nav>
}
Enter fullscreen mode Exit fullscreen mode

When notifications updates every 30 seconds, Navbar re-renders even though it only cares about user. This is the source of most Context performance complaints.

The Actual Fix: Split the Context

One large context object is the problem. Split by update frequency:

const UserContext = createContext<User | null>(null)
const UIContext = createContext<{ sidebarOpen: boolean; toggleSidebar: () => void }>(/* ... */)
const NotificationContext = createContext<Notification[]>([])

function Navbar() {
  const user = useContext(UserContext) // only re-renders when user changes
  return <nav>{user?.name}</nav>
}
Enter fullscreen mode Exit fullscreen mode

Most Context performance problems are solved by this split alone — no library needed.

When Context Is the Right Choice

Context excels for values that are stable and needed widely:

// Theme — changes once per session at most
const ThemeContext = createContext<ThemeContextValue | null>(null)

// Auth user — changes on sign in / sign out
const AuthContext = createContext<AuthContextValue | null>(null)

// Locale — changes when user switches language
const LocaleContext = createContext<LocaleContextValue | null>(null)
Enter fullscreen mode Exit fullscreen mode

The common thread: stable values that don't update frequently.

Context also works well for SSR — Zustand is client-side only, but Context is readable in Server Components if you avoid state that requires useState.

When Zustand Fixes the Actual Problem

Zustand's core advantage: components subscribe to specific slices, not the whole store.

import { create } from 'zustand'

const useAppStore = create<AppStore>((set) => ({
  user: null,
  sidebarOpen: false,
  notifications: [],
  setUser: (user) => set({ user }),
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  addNotification: (n) => set((s) => ({ notifications: [...s.notifications, n] })),
}))

function Navbar() {
  // Only re-renders when user changes — ignores sidebarOpen and notifications
  const user = useAppStore((s) => s.user)
  return <nav>{user?.name}</nav>
}

function Sidebar() {
  // Only re-renders when sidebarOpen changes
  const { sidebarOpen, toggleSidebar } = useAppStore((s) => ({
    sidebarOpen: s.sidebarOpen,
    toggleSidebar: s.toggleSidebar,
  }))
  return <aside className={sidebarOpen ? 'w-64' : 'w-0'}>...</aside>
}

function NotificationBell() {
  // Notifications updating 30x/day doesn't touch Navbar or Sidebar
  const count = useAppStore((s) => s.notifications.length)
  return <span>{count}</span>
}
Enter fullscreen mode Exit fullscreen mode

Selector-based subscription is what Context fundamentally can't do without extra infrastructure.

Combining Both (Real Production Setup)

They're not mutually exclusive:

// Context: auth and theme — stable, available server-side
const AuthContext = createContext<AuthContextValue | null>(null)
const ThemeContext = createContext<ThemeContextValue | null>(null)

// Zustand: client-side app state — complex, selective subscriptions
const useUIStore = create<UIStore>(/* ... */)
const useDataStore = create<DataStore>(/* ... */)
Enter fullscreen mode Exit fullscreen mode

Use Context for values that are stable and available at render time. Use Zustand for values that change frequently and need selective subscriptions.

The Decision Framework

New state?
│
├─ Stable? (theme, auth, locale) → React Context
├─ SSR/RSC readable? → React Context
├─ Simple prop drilling? → React Context (split contexts)
│
├─ Changes frequently? → Zustand
├─ Components need different slices? → Zustand
├─ Complex logic (derived, middleware)? → Zustand
│
└─ Fetched from an API? → TanStack Query (not Context or Zustand)
Enter fullscreen mode Exit fullscreen mode

That last point matters: server state (API data) is a different category. Neither Context nor Zustand handles caching, background refetching, or stale-while-revalidate correctly. TanStack Query does.

Common Mistakes

Putting everything in one context object — the split is the fix.

Using Zustand for stable values — overkill, and adds client-only constraints.

Using Context for frequently-changing values — causes unnecessary re-renders across the tree.

Using Zustand for server state — that's TanStack Query's job.


Full article at stacknotice.com/blog/react-context-vs-zustand-2026

Top comments (0)