Command Palette

Search for a command to run...

Blog
PreviousNext

State Management in React: When to Use Zustand vs Context API

A practical comparison of Zustand and React Context API based on real-world experience building enterprise applications.

Introduction

After building the Reusable Solutions Framework at Quantiphi using Zustand, and working on multiple React projects with Context API, I've developed strong opinions about when to use each approach.

Let me save you weeks of refactoring by sharing what I've learned.

The Quick Decision Tree

Use Zustand when:

  • Managing global application state
  • Need performance optimization
  • Frequent state updates
  • Complex state logic
  • Multiple components need the same state

Use Context API when:

  • Passing props through multiple levels
  • Theme or locale data
  • Authentication state (simple cases)
  • Small, isolated feature state
  • You want zero dependencies

Understanding the Basics

React Context API

Built into React, Context provides a way to pass data through the component tree without prop drilling:

import { createContext, useContext, useState } from 'react';
 
const ThemeContext = createContext();
 
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
 
function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}
 
// Usage
function Button() {
  const { theme, setTheme } = useTheme();
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Current: {theme}
    </button>
  );
}

Zustand

A lightweight state management library with a simple API:

import { create } from 'zustand';
 
const useThemeStore = create((set) => ({
  theme: 'light',
  setTheme: (theme) => set({ theme }),
  toggleTheme: () => set((state) => ({ 
    theme: state.theme === 'light' ? 'dark' : 'light' 
  }))
}));
 
// Usage
function Button() {
  const { theme, toggleTheme } = useThemeStore();
  return (
    <button onClick={toggleTheme}>
      Current: {theme}
    </button>
  );
}

The Performance Problem with Context

Here's where Context API struggles - every consumer re-renders when any part of the context changes:

// ❌ BAD: All consumers re-render on any state change
const AppContext = createContext();
 
function AppProvider({ children }) {
  const [user, setUser] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [settings, setSettings] = useState({});
  
  // When notifications update, EVERY component using AppContext re-renders
  return (
    <AppContext.Provider value={{ 
      user, setUser,
      notifications, setNotifications,
      settings, setSettings 
    }}>
      {children}
    </AppContext.Provider>
  );
}
 
// This component only needs user, but re-renders on any change
function UserProfile() {
  const { user } = useContext(AppContext);
  console.log('UserProfile rendered'); // Logs on notification changes too!
  return <div>{user?.name}</div>;
}

Zustand solves this with selectors:

// ✅ GOOD: Only re-renders when selected state changes
const useAppStore = create((set) => ({
  user: null,
  notifications: [],
  settings: {},
  setUser: (user) => set({ user }),
  addNotification: (notification) => 
    set((state) => ({ 
      notifications: [...state.notifications, notification] 
    })),
  updateSettings: (settings) => set({ settings })
}));
 
// Only re-renders when user changes
function UserProfile() {
  const user = useAppStore((state) => state.user);
  console.log('UserProfile rendered'); // Only logs when user changes!
  return <div>{user?.name}</div>;
}

Real-World Example: Chat Application

Let me show you a real scenario from our AI chat application:

Context API Approach (What We Started With)

// ❌ Performance issues with frequent updates
const ChatContext = createContext();
 
function ChatProvider({ children }) {
  const [messages, setMessages] = useState([]);
  const [isTyping, setIsTyping] = useState(false);
  const [connectionStatus, setConnectionStatus] = useState('connected');
  const [unreadCount, setUnreadCount] = useState(0);
  
  const addMessage = (message) => {
    setMessages(prev => [...prev, message]);
  };
  
  return (
    <ChatContext.Provider value={{
      messages,
      isTyping,
      connectionStatus,
      unreadCount,
      addMessage,
      setIsTyping,
      setConnectionStatus
    }}>
      {children}
    </ChatContext.Provider>
  );
}
 
// Problem: Header re-renders on EVERY message
function ChatHeader() {
  const { unreadCount, connectionStatus } = useContext(ChatContext);
  console.log('Header re-rendered'); // Logs on every message!
  
  return (
    <header>
      <ConnectionIndicator status={connectionStatus} />
      <UnreadBadge count={unreadCount} />
    </header>
  );
}
 
// Problem: Message list re-renders when typing indicator changes
function MessageList() {
  const { messages } = useContext(ChatContext);
  console.log('Messages re-rendered'); // Logs when typing indicator changes!
  
  return (
    <div>
      {messages.map(msg => <Message key={msg.id} {...msg} />)}
    </div>
  );
}

Zustand Approach (Our Refactor)

// ✅ Optimized with selectors
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
 
const useChatStore = create(
  devtools((set, get) => ({
    messages: [],
    isTyping: false,
    connectionStatus: 'connected',
    unreadCount: 0,
    
    // Actions
    addMessage: (message) => set((state) => ({
      messages: [...state.messages, message],
      unreadCount: state.unreadCount + 1
    })),
    
    setTyping: (isTyping) => set({ isTyping }),
    
    setConnectionStatus: (status) => set({ connectionStatus: status }),
    
    markAsRead: () => set({ unreadCount: 0 }),
    
    clearMessages: () => set({ messages: [], unreadCount: 0 })
  }))
);
 
// Only re-renders when unreadCount or connectionStatus changes
function ChatHeader() {
  const unreadCount = useChatStore((state) => state.unreadCount);
  const connectionStatus = useChatStore((state) => state.connectionStatus);
  console.log('Header re-rendered'); // Only when these values change!
  
  return (
    <header>
      <ConnectionIndicator status={connectionStatus} />
      <UnreadBadge count={unreadCount} />
    </header>
  );
}
 
// Only re-renders when messages array changes
function MessageList() {
  const messages = useChatStore((state) => state.messages);
  console.log('Messages re-rendered'); // Only when messages change!
  
  return (
    <div>
      {messages.map(msg => <Message key={msg.id} {...msg} />)}
    </div>
  );
}
 
// Only re-renders when typing indicator changes
function TypingIndicator() {
  const isTyping = useChatStore((state) => state.isTyping);
  return isTyping ? <span>User is typing...</span> : null;
}

Performance impact:

  • Context: ~60 re-renders per message
  • Zustand: ~3 re-renders per message
  • 20x improvement!

Advanced Zustand Patterns

1. Computed Values

const useCartStore = create((set, get) => ({
  items: [],
  
  addItem: (item) => set((state) => ({
    items: [...state.items, item]
  })),
  
  // Computed value as function
  get total() {
    return get().items.reduce((sum, item) => sum + item.price, 0);
  },
  
  get itemCount() {
    return get().items.length;
  }
}));
 
// Usage
function CartSummary() {
  const total = useCartStore((state) => state.total);
  const itemCount = useCartStore((state) => state.itemCount);
  
  return <div>{itemCount} items - ${total}</div>;
}

2. Middleware for Persistence

import { persist } from 'zustand/middleware';
 
const useAuthStore = create(
  persist(
    (set) => ({
      user: null,
      token: null,
      
      login: (user, token) => set({ user, token }),
      logout: () => set({ user: null, token: null })
    }),
    {
      name: 'auth-storage', // localStorage key
      partialize: (state) => ({ 
        // Only persist these fields
        user: state.user,
        token: state.token
      })
    }
  )
);

3. Async Actions

const useDataStore = create((set, get) => ({
  data: null,
  loading: false,
  error: null,
  
  fetchData: async (id) => {
    set({ loading: true, error: null });
    
    try {
      const response = await fetch(`/api/data/${id}`);
      const data = await response.json();
      
      set({ data, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
  
  // Optimistic updates
  updateData: async (id, updates) => {
    // Optimistic update
    const previousData = get().data;
    set({ data: { ...previousData, ...updates } });
    
    try {
      await fetch(`/api/data/${id}`, {
        method: 'PATCH',
        body: JSON.stringify(updates)
      });
    } catch (error) {
      // Revert on failure
      set({ data: previousData, error: error.message });
    }
  }
}));

4. Slices Pattern (For Large Stores)

// userSlice.js
export const createUserSlice = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null })
});
 
// settingsSlice.js
export const createSettingsSlice = (set) => ({
  theme: 'light',
  language: 'en',
  setTheme: (theme) => set({ theme }),
  setLanguage: (language) => set({ language })
});
 
// store.js
import { create } from 'zustand';
import { createUserSlice } from './userSlice';
import { createSettingsSlice } from './settingsSlice';
 
export const useAppStore = create((...a) => ({
  ...createUserSlice(...a),
  ...createSettingsSlice(...a)
}));

When Context API is Still Better

1. Theme Provider (Simple, Infrequent Updates)

// ✅ Good use of Context
const ThemeContext = createContext();
 
export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  // Theme changes are infrequent
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

2. Feature-Specific State

// ✅ Good: Isolated modal state
function ModalProvider({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  const [content, setContent] = useState(null);
  
  return (
    <ModalContext.Provider value={{ isOpen, setIsOpen, content, setContent }}>
      {children}
    </ModalContext.Provider>
  );
}

My Recommendation

After 2+ years building enterprise React apps:

Use Zustand for:

  • Global app state (user, auth, app settings)
  • Feature stores with frequent updates (chat, notifications, real-time data)
  • Cross-feature state sharing
  • When you need DevTools support
  • Performance-critical applications

Use Context for:

  • Theme and locale
  • Routing state (if not using React Router)
  • Feature-specific state (modals, wizards)
  • Dependency injection patterns
  • When your state is truly stable

Migration Path

If you're currently using Context and experiencing performance issues:

  1. Identify hot paths: Use React DevTools Profiler
  2. Start with one store: Migrate the most frequently updated state
  3. Keep Context for stable data: No need to migrate everything
  4. Use both together: They complement each other
// Hybrid approach
function App() {
  return (
    <ThemeProvider>  {/* Context for theme */}
      <LocaleProvider>  {/* Context for locale */}
        <Router>
          {/* Zustand handles everything else */}
          <AppContent />
        </Router>
      </LocaleProvider>
    </ThemeProvider>
  );
}

Conclusion

Both tools have their place. Zustand shines for frequently updated global state, while Context API is perfect for stable, tree-scoped data.

In our Reusable Solutions Framework, using Zustand reduced re-renders by 60% and improved user-perceived performance significantly.

Choose based on your needs, not hype.

Resources


Questions about state management? Connect with me on LinkedIn.