React
|stacknotice.com
10 min left|
0%
|2,000 words
React

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

React Context isn't just for theming, and Zustand isn't always the answer. Here's when to use each, why Context causes re-renders, and how to fix it.

C
Carlos Oliva
Software Developer
June 25, 202610 min read
Share:
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.

Context isn't broken. It's just frequently misapplied. And Zustand isn't a universal upgrade — it solves specific problems that Context genuinely has.

This guide explains what each actually does, the re-render problem that makes Context painful at scale, and a clear decision framework for choosing between them.

What React Context Actually Does

Context has one job: make a value available to any component in a subtree without passing it through props.

// Create the context
const ThemeContext = createContext<'light' | 'dark'>('light')
 
// Provide the value
function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Sidebar />
      <Main />
    </ThemeContext.Provider>
  )
}
 
// Consume it anywhere in the subtree
function Button() {
  const theme = useContext(ThemeContext)
  return <button className={theme === 'dark' ? 'bg-zinc-800' : 'bg-white'}>Click</button>
}

That's all it is. No built-in subscriptions, no selectors, no optimized re-render logic — just a mechanism for passing a value down a component tree without prop drilling.

The Re-Render Problem

Here's where Context trips people up: every component that calls useContext(SomeContext) re-renders whenever the context value changes — even if the specific piece of data that component cares about didn't change.

interface AppState {
  user: User | null
  theme: 'light' | 'dark'
  sidebarOpen: boolean
  notifications: Notification[]
}
 
const AppContext = createContext<AppState>(/* ... */)
 
function Navbar() {
  const { user } = useContext(AppContext) // subscribes to the entire context
  return <nav>{user?.name}</nav>
}
 
function Sidebar() {
  const { sidebarOpen } = useContext(AppContext) // also subscribes to everything
  return <aside className={sidebarOpen ? 'w-64' : 'w-0'}>...</aside>
}

When notifications updates (say, a new notification arrives every 30 seconds), both Navbar and Sidebar re-render — even though neither cares about notifications. They subscribed to the whole context object.

This is the source of most Context performance complaints. The fix isn't switching to Zustand — it's splitting the context.

The Real Fix: Split Your Context

One big context object is the actual problem. Split it by update frequency:

// Separate contexts for separate concerns
const UserContext = createContext<User | null>(null)
const ThemeContext = createContext<'light' | 'dark'>('light')
const UIContext = createContext<{ sidebarOpen: boolean; toggleSidebar: () => void }>({
  sidebarOpen: false,
  toggleSidebar: () => {},
})
const NotificationContext = createContext<Notification[]>([])
 
function Navbar() {
  const user = useContext(UserContext) // only re-renders when user changes
  return <nav>{user?.name}</nav>
}
 
function Sidebar() {
  const { sidebarOpen } = useContext(UIContext) // only re-renders when UI state changes
  return <aside className={sidebarOpen ? 'w-64' : 'w-0'}>...</aside>
}

Now each component only re-renders when its specific context updates. Notifications updating 30 times per day doesn't touch Navbar or Sidebar at all.

Most React Context performance problems are actually solved by this split — not by adding a state management library.

When Context Is the Right Choice

Context excels for values that change infrequently or that a large portion of the component tree genuinely needs:

Theme and appearance settings

type Theme = 'light' | 'dark' | 'system'
 
interface ThemeContextValue {
  theme: Theme
  resolvedTheme: 'light' | 'dark'
  setTheme: (theme: Theme) => void
}
 
const ThemeContext = createContext<ThemeContextValue | null>(null)
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('system')
  const systemTheme = useSystemTheme()
  const resolvedTheme = theme === 'system' ? systemTheme : theme
 
  return (
    <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}
 
export function useTheme() {
  const ctx = useContext(ThemeContext)
  if (!ctx) throw new Error('useTheme must be inside ThemeProvider')
  return ctx
}

Theme changes rarely. Every component that uses it re-rendering on theme change is fine — you want that.

Auth user object

interface AuthContextValue {
  user: User | null
  isLoading: boolean
  signOut: () => Promise<void>
}
 
const AuthContext = createContext<AuthContextValue | null>(null)
 
export function useAuth() {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be inside AuthProvider')
  return ctx
}

The auth user changes once (sign in) or once (sign out). Broadcasting that to everything is correct behavior.

Locale and i18n

const LocaleContext = createContext<{ locale: string; t: (key: string) => string } | null>(null)

Changes maybe once per session when the user switches language. Context is ideal.

The common thread: these are values that are stable most of the time and represent information that most of the app genuinely needs.

When Context Causes Real Problems

Context struggles when:

1. The value changes frequently

// Bad: mouse position updating 60fps through context
const MouseContext = createContext({ x: 0, y: 0 })
 
function Tracker() {
  const [pos, setPos] = useState({ x: 0, y: 0 })
 
  // This causes every consumer to re-render at 60fps
  return (
    <MouseContext.Provider value={pos}>
      {children}
    </MouseContext.Provider>
  )
}

2. Consumers need selective subscriptions

If 20 components use a context but each cares about a different field, splitting helps. But if the object is complex enough that splitting it means 10 contexts, you're fighting the grain of the API.

3. State logic is complex

Context doesn't have built-in support for slices, derived state, or subscriptions to specific fields. You have to build that yourself with useReducer + useMemo + memo(), which gets verbose quickly.

4. State needs to be shared across unrelated subtrees

Context is hierarchical — it lives in a provider that wraps a subtree. If two unrelated parts of the app need the same state, you end up hoisting the provider very high (possibly to the root), which defeats the granularity benefits.

Where Zustand Fixes the Actual Problem

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

import { create } from 'zustand'
 
interface AppStore {
  user: User | null
  sidebarOpen: boolean
  notifications: Notification[]
  setUser: (user: User | null) => void
  toggleSidebar: () => void
  addNotification: (n: Notification) => void
}
 
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] })),
}))

Now each component subscribes to exactly what it needs:

function Navbar() {
  // Only re-renders when user changes — sidebarOpen and notifications are ignored
  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'}>
      <button onClick={toggleSidebar}>Close</button>
    </aside>
  )
}
 
function NotificationBell() {
  // Only re-renders when notifications change
  const count = useAppStore((s) => s.notifications.length)
  return <span>{count}</span>
}

Each component re-renders only when its specific slice changes. NotificationBell updating every 30 seconds doesn't touch Navbar or Sidebar.

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

For a full Zustand setup including slices, devtools, and persistence, see the Zustand complete guide.

Combining Context and Zustand

They aren't mutually exclusive. A common production setup:

// Context: auth (stable, needs to be available during SSR)
const AuthContext = createContext<AuthContextValue | null>(null)
 
// Context: theme (stable, read by every component)
const ThemeContext = createContext<ThemeContextValue | null>(null)
 
// Zustand: client-side UI state (complex, selective subscriptions needed)
const useUIStore = create<UIStore>(/* ... */)
 
// Zustand: app data that changes frequently
const useDataStore = create<DataStore>(/* ... */)

Use Context for values that are:

  • Stable most of the time
  • Available server-side (important for RSC and SSR)
  • Read by almost the whole tree

Use Zustand for values that are:

  • Client-side only
  • Updated frequently
  • Consumed by specific components that need selective subscriptions

Jotai and Other Alternatives

Jotai takes a different approach: atoms instead of a single store. Each piece of state is a separate atom, and components subscribe to specific atoms.

import { atom, useAtom } from 'jotai'
 
const sidebarOpenAtom = atom(false)
const notificationsAtom = atom<Notification[]>([])
 
function Sidebar() {
  const [sidebarOpen, setSidebarOpen] = useAtom(sidebarOpenAtom)
  // ...
}

Jotai is a natural fit when your state is many independent pieces that rarely need to be read together. Zustand is better when state pieces are logically related and you want a single source of truth with clear actions.

Valtio wraps state in a Proxy and tracks access automatically — no selectors needed. Useful if you find selector functions verbose.

For most applications, Zustand hits the right balance of explicitness and power.

The Decision Framework

New state needed
│
├── Is it stable? (changes rarely — theme, auth user, locale)
│   └── YES → React Context
│
├── Is it server-side readable? (SSR, RSC)
│   └── YES → React Context
│
├── Is it simple prop drilling you want to avoid?
│   └── YES → React Context (with split contexts)
│
├── Does it change frequently?
│   └── YES → Zustand
│
├── Do different components need different slices?
│   └── YES → Zustand
│
├── Is the logic complex? (derived state, middleware, devtools)
│   └── YES → Zustand
│
└── Is it server state? (fetched data, cache, background refetching)
    └── YES → TanStack Query (not Context or Zustand)

That last branch matters: server state (data from an API) is a different category entirely. Neither Context nor Zustand is the right tool for it — TanStack Query handles caching, background refetching, and stale-while-revalidate semantics that Context and Zustand don't.

Summary

Context has one real limitation: no selective subscriptions. Every consumer re-renders on every value change. For stable, infrequently-changing values, this doesn't matter. For frequently-changing values or large stores with many consumers, it becomes a problem.

Zustand solves that specific problem with selector functions. It doesn't replace Context for everything — it replaces Context for the cases where Context's re-render behavior creates visible performance issues.

The pattern that works: Context for auth, theme, locale. Zustand for client-side application state. TanStack Query for server state. Each doing the job it was designed for.

For more on React performance patterns and when re-renders actually matter, see the React anti-patterns and performance guide.

#react#zustand#state-management#performance#typescript
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.