React.js Fundamentals

State Management with Zustand

15 min Lesson 32 of 40

State Management with Zustand

Zustand is a lightweight state management library for React that provides a simple and intuitive API. It's smaller and faster than Redux, with minimal boilerplate and excellent TypeScript support.

Why Zustand?

Zustand offers several advantages over traditional state management solutions:

  • Minimal Boilerplate: No providers, actions, or reducers required
  • Small Bundle Size: Only ~1KB gzipped
  • TypeScript Support: First-class TypeScript support
  • Developer Experience: Simple API with React DevTools integration
  • Performance: No unnecessary re-renders

Setting Up Zustand

First, install Zustand in your project:

npm install zustand

Create your first store:

import { create } from 'zustand'; const useCounterStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 }), })); // Using the store in a component function Counter() { const { count, increment, decrement, reset } = useCounterStore(); return ( <div> <h2>Count: {count}</h2> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> <button onClick={reset}>Reset</button> </div> ); }
Note: Unlike Context API, Zustand doesn't require wrapping your app with a Provider. You can use the store directly in any component.

Creating a Real-World Store

Let's create a shopping cart store with multiple features:

import { create } from 'zustand'; const useCartStore = create((set, get) => ({ items: [], total: 0, // Add item to cart addItem: (product) => set((state) => { const existingItem = state.items.find((item) => item.id === product.id); if (existingItem) { // Increase quantity if item already exists return { items: state.items.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item ), }; } // Add new item return { items: [...state.items, { ...product, quantity: 1 }], }; }), // Remove item from cart removeItem: (productId) => set((state) => ({ items: state.items.filter((item) => item.id !== productId), })), // Update item quantity updateQuantity: (productId, quantity) => set((state) => ({ items: state.items.map((item) => item.id === productId ? { ...item, quantity } : item ), })), // Clear cart clearCart: () => set({ items: [] }), // Get total price getTotal: () => { const state = get(); return state.items.reduce( (total, item) => total + item.price * item.quantity, 0 ); }, // Get item count getItemCount: () => { const state = get(); return state.items.reduce((count, item) => count + item.quantity, 0); }, })); // Using the cart store function ShoppingCart() { const { items, removeItem, updateQuantity, clearCart, getTotal } = useCartStore(); return ( <div> <h2>Shopping Cart</h2> {items.length === 0 ? ( <p>Your cart is empty</p> ) : ( <> {items.map((item) => ( <div key={item.id} className="cart-item"> <h3>{item.name}</h3> <p>${item.price}</p> <input type="number" min="1" value={item.quantity} onChange={(e) => updateQuantity(item.id, parseInt(e.target.value)) } /> <button onClick={() => removeItem(item.id)}>Remove</button> </div> ))} <div className="cart-total"> <h3>Total: ${getTotal()}</h3> <button onClick={clearCart}>Clear Cart</button> </div> </> )} </div> ); }

Using Selectors for Performance

Selectors allow you to subscribe to specific parts of the state, preventing unnecessary re-renders:

import { create } from 'zustand'; const useUserStore = create((set) => ({ user: { name: 'John Doe', email: 'john@example.com', settings: { theme: 'light', notifications: true, }, }, updateUser: (updates) => set((state) => ({ user: { ...state.user, ...updates }, })), updateSettings: (settings) => set((state) => ({ user: { ...state.user, settings: { ...state.user.settings, ...settings }, }, })), })); // Component only re-renders when theme changes function ThemeToggle() { const theme = useUserStore((state) => state.user.settings.theme); const updateSettings = useUserStore((state) => state.updateSettings); return ( <button onClick={() => updateSettings({ theme: theme === 'light' ? 'dark' : 'light', }) } > Toggle Theme ({theme}) </button> ); } // Component only re-renders when name changes function UserGreeting() { const name = useUserStore((state) => state.user.name); return <h1>Hello, {name}!</h1>; }
Tip: Use selectors to optimize performance. Components will only re-render when the selected state changes, not when any part of the store updates.

Middleware: Persist

The persist middleware allows you to save your store to localStorage or sessionStorage:

import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; const usePreferencesStore = create( persist( (set) => ({ theme: 'light', language: 'en', fontSize: 16, setTheme: (theme) => set({ theme }), setLanguage: (language) => set({ language }), setFontSize: (fontSize) => set({ fontSize }), resetPreferences: () => set({ theme: 'light', language: 'en', fontSize: 16, }), }), { name: 'user-preferences', // localStorage key storage: createJSONStorage(() => localStorage), // default is localStorage } ) ); // Using persisted store function Preferences() { const { theme, language, fontSize, setTheme, setLanguage, setFontSize } = usePreferencesStore(); return ( <div> <h2>User Preferences</h2> <label> Theme: <select value={theme} onChange={(e) => setTheme(e.target.value)}> <option value="light">Light</option> <option value="dark">Dark</option> </select> </label> <label> Language: <select value={language} onChange={(e) => setLanguage(e.target.value)}> <option value="en">English</option> <option value="es">Spanish</option> <option value="fr">French</option> </select> </label> <label> Font Size: <input type="range" min="12" max="24" value={fontSize} onChange={(e) => setFontSize(parseInt(e.target.value))} /> {fontSize}px </label> </div> ); }
Warning: Be careful with what you persist. Sensitive data like passwords or API tokens should never be stored in localStorage.

Middleware: DevTools

Connect your Zustand store to Redux DevTools for debugging:

import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; const useTodoStore = create( devtools( (set) => ({ todos: [], addTodo: (text) => set( (state) => ({ todos: [ ...state.todos, { id: Date.now(), text, completed: false }, ], }), false, 'todos/add' // Action name in DevTools ), toggleTodo: (id) => set( (state) => ({ todos: state.todos.map((todo) => todo.id === id ? { ...todo, completed: !todo.completed } : todo ), }), false, 'todos/toggle' ), removeTodo: (id) => set( (state) => ({ todos: state.todos.filter((todo) => todo.id !== id), }), false, 'todos/remove' ), }), { name: 'TodoStore' } ) );

Combining Multiple Middleware

You can combine persist and devtools middleware:

import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { devtools } from 'zustand/middleware'; const useAuthStore = create( devtools( persist( (set) => ({ user: null, token: null, isAuthenticated: false, login: (user, token) => set( { user, token, isAuthenticated: true, }, false, 'auth/login' ), logout: () => set( { user: null, token: null, isAuthenticated: false, }, false, 'auth/logout' ), updateUser: (updates) => set( (state) => ({ user: { ...state.user, ...updates }, }), false, 'auth/updateUser' ), }), { name: 'auth-storage', storage: createJSONStorage(() => localStorage), } ), { name: 'AuthStore' } ) ); // Protected route component function ProtectedRoute({ children }) { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); if (!isAuthenticated) { return <Navigate to="/login" />; } return children; } // Login component function Login() { const login = useAuthStore((state) => state.login); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const handleSubmit = async (e) => { e.preventDefault(); try { const response = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const data = await response.json(); login(data.user, data.token); } catch (error) { console.error('Login failed:', error); } }; return ( <form onSubmit={handleSubmit}> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" required /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" required /> <button type="submit">Login</button> </form> ); }
Tip: When using multiple middleware, the order matters. Generally, use: devtools(persist(store)) to ensure DevTools captures all actions including persisted ones.

Async Actions and API Integration

Handle asynchronous operations in Zustand stores:

import { create } from 'zustand'; const useProductStore = create((set) => ({ products: [], loading: false, error: null, fetchProducts: async () => { set({ loading: true, error: null }); try { const response = await fetch('https://api.example.com/products'); if (!response.ok) { throw new Error('Failed to fetch products'); } const data = await response.json(); set({ products: data, loading: false }); } catch (error) { set({ error: error.message, loading: false }); } }, createProduct: async (product) => { set({ loading: true, error: null }); try { const response = await fetch('https://api.example.com/products', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(product), }); if (!response.ok) { throw new Error('Failed to create product'); } const data = await response.json(); set((state) => ({ products: [...state.products, data], loading: false, })); } catch (error) { set({ error: error.message, loading: false }); } }, deleteProduct: async (id) => { set({ loading: true, error: null }); try { const response = await fetch(`https://api.example.com/products/${id}`, { method: 'DELETE', }); if (!response.ok) { throw new Error('Failed to delete product'); } set((state) => ({ products: state.products.filter((p) => p.id !== id), loading: false, })); } catch (error) { set({ error: error.message, loading: false }); } }, })); // Using the async store function ProductList() { const { products, loading, error, fetchProducts } = useProductStore(); useEffect(() => { fetchProducts(); }, [fetchProducts]); if (loading) return <div>Loading...</div>; if (error) return <div>Error: {error}</div>; return ( <div> {products.map((product) => ( <div key={product.id}> <h3>{product.name}</h3> <p>${product.price}</p> </div> ))} </div> ); }
Exercise 1: Create a notification store using Zustand that can add, remove, and auto-dismiss notifications after 3 seconds. Include notification types (success, error, warning, info) and persist dismissed notification IDs to localStorage.
Exercise 2: Build a multi-step form store that tracks the current step, form data for each step, and validation errors. Implement functions to go to next/previous steps, save form data, and submit the complete form.
Exercise 3: Create a search and filter store for a product catalog. Include filters for category, price range, and rating. Implement debounced search and persist the last search query and filters using the persist middleware.