UNPKG

agent-rules-kit

Version:

Bootstrap of **Cursor** rules (`.mdc`) and mirror documentation (`.md`) for AI agent-guided projects.

912 lines (738 loc) 20.9 kB
# Zustand State Management in React Zustand (German for "state") is a small, fast, and scalable state-management solution for React. It uses a simplified flux-like store pattern with hooks, making it both powerful and easy to use. ## Core Concepts 1. **Store**: A hook-based store containing state and actions 2. **Selectors**: Functions to extract specific pieces of state 3. **Actions**: Functions that modify state 4. **Middlewares**: Enhancers that add functionality to stores 5. **Immutability**: All state updates are immutable ## Key Advantages - **Minimal API**: Learn the entire API in minutes - **No Boilerplate**: No reducers, action types, or dispatchers - **No Context Providers**: Direct store consumption without providers - **No Re-renders**: Subscribes to state portions, not the entire store - **Middleware**: Includes devtools, persistence, and immer integration ## Directory Structure ``` src/ ├── stores/ │ ├── index.js # Exports all stores (optional) │ ├── useAuthStore.js # Authentication store │ ├── useCartStore.js # Shopping cart store │ └── useSettingsStore.js # Application settings store └── components/ ├── LoginForm.jsx # Uses auth store ├── ShoppingCart.jsx # Uses cart store └── ThemeToggle.jsx # Uses settings store ``` ## Setup and Installation Install Zustand: ```bash npm install zustand # or with yarn yarn add zustand ``` ## Basic Implementation ### Creating a Store ```javascript // stores/useCounterStore.js import create from 'zustand'; // Create a store const useCounterStore = create((set) => ({ // Initial state count: 0, // Actions (functions that modify state) increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 }), incrementBy: (amount) => set((state) => ({ count: state.count + amount })), })); export default useCounterStore; ``` ### Using the Store in Components ```jsx // components/Counter.jsx import React from 'react'; import useCounterStore from '../stores/useCounterStore'; const Counter = () => { // Extract only what you need (components will only re-render when these values change) const count = useCounterStore((state) => state.count); const { increment, decrement, reset } = useCounterStore((state) => ({ increment: state.increment, decrement: state.decrement, reset: state.reset, })); return ( <div> <h2>Count: {count}</h2> <button onClick={increment}>Increment</button> <button onClick={decrement}>Decrement</button> <button onClick={reset}>Reset</button> </div> ); }; export default Counter; ``` ## Advanced Patterns ### Async Actions ```javascript // stores/useTodoStore.js import create from 'zustand'; const useTodoStore = create((set, get) => ({ todos: [], isLoading: false, error: null, // Synchronous actions setLoading: (isLoading) => set({ isLoading }), setError: (error) => set({ error }), setTodos: (todos) => set({ todos }), // Asynchronous action fetchTodos: async () => { try { set({ isLoading: true, error: null }); const response = await fetch( 'https://jsonplaceholder.typicode.com/todos' ); const todos = await response.json(); set({ todos, isLoading: false }); } catch (error) { set({ error: error.message, isLoading: false }); } }, addTodo: async (title) => { try { set({ isLoading: true, error: null }); const response = await fetch( 'https://jsonplaceholder.typicode.com/todos', { method: 'POST', body: JSON.stringify({ title, completed: false, userId: 1, }), headers: { 'Content-type': 'application/json; charset=UTF-8', }, } ); const newTodo = await response.json(); // Update the local state with the new todo set((state) => ({ todos: [...state.todos, newTodo], isLoading: false, })); } catch (error) { set({ error: error.message, isLoading: false }); } }, toggleTodo: (id) => { set((state) => ({ todos: state.todos.map((todo) => todo.id === id ? { ...todo, completed: !todo.completed } : todo ), })); }, })); export default useTodoStore; ``` ### Using the Todo Store ```jsx // components/TodoList.jsx import React, { useEffect, useState } from 'react'; import useTodoStore from '../stores/useTodoStore'; const TodoList = () => { const [newTodoTitle, setNewTodoTitle] = useState(''); // Extract state and actions const { todos, isLoading, error, fetchTodos, addTodo, toggleTodo } = useTodoStore(); // Load todos on component mount useEffect(() => { fetchTodos(); }, [fetchTodos]); const handleSubmit = (e) => { e.preventDefault(); if (!newTodoTitle.trim()) return; addTodo(newTodoTitle); setNewTodoTitle(''); }; if (isLoading && todos.length === 0) { return <div>Loading todos...</div>; } if (error) { return <div>Error: {error}</div>; } return ( <div> <h2>Todo List</h2> <form onSubmit={handleSubmit}> <input type='text' value={newTodoTitle} onChange={(e) => setNewTodoTitle(e.target.value)} placeholder='Add a new todo' /> <button type='submit' disabled={isLoading}> {isLoading ? 'Adding...' : 'Add Todo'} </button> </form> <ul> {todos.map((todo) => ( <li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none', cursor: 'pointer', }} onClick={() => toggleTodo(todo.id)}> {todo.title} </li> ))} </ul> </div> ); }; export default TodoList; ``` ## Middleware ### 1. Using Immer for Simplified State Updates Immer allows you to write "mutative" code while maintaining immutability: ```javascript // stores/useUserStore.js import create from 'zustand'; import { immer } from 'zustand/middleware/immer'; const useUserStore = create( immer((set) => ({ users: [], selectedUserId: null, addUser: (user) => set((state) => { // "Mutate" the state directly - Immer handles immutability state.users.push(user); }), updateUser: (userId, updates) => set((state) => { const userIndex = state.users.findIndex( (user) => user.id === userId ); if (userIndex !== -1) { // Direct "mutation" is safe with Immer Object.assign(state.users[userIndex], updates); } }), deleteUser: (userId) => set((state) => { const userIndex = state.users.findIndex( (user) => user.id === userId ); if (userIndex !== -1) { state.users.splice(userIndex, 1); } // Reset selectedUserId if it's the deleted user if (state.selectedUserId === userId) { state.selectedUserId = null; } }), selectUser: (userId) => set((state) => { state.selectedUserId = userId; }), })) ); export default useUserStore; ``` ### 2. Persistence Middleware Save and load state from localStorage: ```javascript // stores/useSettingsStore.js import create from 'zustand'; import { persist } from 'zustand/middleware'; const useSettingsStore = create( persist( (set) => ({ theme: 'light', fontSize: 'medium', notifications: true, setTheme: (theme) => set({ theme }), setFontSize: (fontSize) => set({ fontSize }), toggleNotifications: () => set((state) => ({ notifications: !state.notifications, })), resetSettings: () => set({ theme: 'light', fontSize: 'medium', notifications: true, }), }), { name: 'app-settings', // unique name for localStorage getStorage: () => localStorage, // (optional) by default, 'localStorage' is used } ) ); export default useSettingsStore; ``` ### 3. Redux DevTools Integration ```javascript // stores/useCartStore.js import create from 'zustand'; import { devtools } from 'zustand/middleware'; const useCartStore = create( devtools( (set) => ({ items: [], totalItems: 0, totalPrice: 0, addItem: (item) => set((state) => { const existingItem = state.items.find( (i) => i.id === item.id ); const newItems = existingItem ? state.items.map((i) => i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i ) : [...state.items, { ...item, quantity: 1 }]; return { items: newItems, totalItems: state.totalItems + 1, totalPrice: state.totalPrice + item.price, }; }), removeItem: (itemId) => set((state) => { const item = state.items.find((i) => i.id === itemId); if (!item) return state; const newItems = state.items.filter((i) => i.id !== itemId); return { items: newItems, totalItems: state.totalItems - item.quantity, totalPrice: state.totalPrice - item.price * item.quantity, }; }), clearCart: () => set({ items: [], totalItems: 0, totalPrice: 0 }), }), { name: 'cart-store', // unique name for Redux DevTools } ) ); export default useCartStore; ``` ### 4. Combining Multiple Middleware ```javascript // stores/useAuthStore.js import create from 'zustand'; import { devtools, persist, immer } from 'zustand/middleware'; // Combine multiple middlewares const useAuthStore = create( devtools( persist( immer((set) => ({ user: null, token: null, isAuthenticated: false, login: (userData, token) => set((state) => { state.user = userData; state.token = token; state.isAuthenticated = true; }), logout: () => set((state) => { state.user = null; state.token = null; state.isAuthenticated = false; }), updateProfile: (updates) => set((state) => { if (state.user) { Object.assign(state.user, updates); } }), })), { name: 'auth-storage', getStorage: () => localStorage, } ), { name: 'auth-store', } ) ); export default useAuthStore; ``` ## Advanced Usage ### 1. Computed Values (Selectors) ```javascript // Using derived values with selectors import useCartStore from '../stores/useCartStore'; const CartSummary = () => { // Derive discounted price based on cart total const { items, totalPrice, totalItems } = useCartStore(); // Compute values const discountRate = totalPrice > 100 ? 0.1 : 0; const discount = totalPrice * discountRate; const finalPrice = totalPrice - discount; return ( <div className='cart-summary'> <h3>Cart Summary</h3> <p>Items: {totalItems}</p> <p>Subtotal: ${totalPrice.toFixed(2)}</p> {discount > 0 && <p>Discount: -${discount.toFixed(2)}</p>} <p className='final-price'>Total: ${finalPrice.toFixed(2)}</p> </div> ); }; ``` ### 2. Combining Multiple Stores ```javascript // components/Checkout.jsx import React from 'react'; import useCartStore from '../stores/useCartStore'; import useAuthStore from '../stores/useAuthStore'; import useSettingsStore from '../stores/useSettingsStore'; const Checkout = () => { // Get values from different stores const { items, totalPrice, clearCart } = useCartStore(); const { user, isAuthenticated } = useAuthStore(); const { theme } = useSettingsStore(); const handleCheckout = () => { if (!isAuthenticated) { alert('Please log in to checkout'); return; } // Process checkout... alert(`Processing checkout for ${user.name} with total $${totalPrice}`); clearCart(); }; return ( <div className={`checkout-container ${theme}`}> <h2>Checkout</h2> {isAuthenticated ? ( <div className='user-info'> <p>Shipping to: {user.name}</p> <p>Email: {user.email}</p> </div> ) : ( <p>Please log in to checkout</p> )} <div className='cart-items'> {items.map((item) => ( <div key={item.id} className='checkout-item'> <span> {item.name} x {item.quantity} </span> <span>${(item.price * item.quantity).toFixed(2)}</span> </div> ))} </div> <div className='total'> <h3>Total: ${totalPrice.toFixed(2)}</h3> </div> <button onClick={handleCheckout} disabled={!isAuthenticated || items.length === 0}> Complete Purchase </button> </div> ); }; export default Checkout; ``` ### 3. Store Factory Pattern When you need multiple instances of similar stores: ```javascript // stores/createListStore.js import create from 'zustand'; // Store factory function const createListStore = (name) => { return create((set) => ({ items: [], add: (item) => set((state) => ({ items: [...state.items, item], })), remove: (id) => set((state) => ({ items: state.items.filter((item) => item.id !== id), })), clear: () => set({ items: [] }), // Store metadata name, createdAt: new Date().toISOString(), })); }; export default createListStore; ``` ```javascript // Use the factory to create different stores const useTaskListStore = createListStore('tasks'); const useShoppingListStore = createListStore('shopping'); const useBookmarksStore = createListStore('bookmarks'); // Usage in components const TaskList = () => { const { items, add, remove } = useTaskListStore(); // ... }; ``` ## TypeScript Integration ```typescript // stores/useCartStore.ts import create from 'zustand'; // Define types export interface Product { id: string; name: string; price: number; image?: string; } export interface CartItem extends Product { quantity: number; } interface CartState { items: CartItem[]; totalItems: number; totalPrice: number; // Actions addItem: (product: Product) => void; removeItem: (productId: string) => void; updateQuantity: (productId: string, quantity: number) => void; clearCart: () => void; } // Create typed store const useCartStore = create<CartState>((set) => ({ items: [], totalItems: 0, totalPrice: 0, addItem: (product) => set((state) => { const existingItem = state.items.find( (item) => item.id === product.id ); if (existingItem) { const updatedItems = state.items.map((item) => item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item ); return { items: updatedItems, totalItems: state.totalItems + 1, totalPrice: state.totalPrice + product.price, }; } else { const newItem = { ...product, quantity: 1 }; return { items: [...state.items, newItem], totalItems: state.totalItems + 1, totalPrice: state.totalPrice + product.price, }; } }), removeItem: (productId) => set((state) => { const existingItem = state.items.find( (item) => item.id === productId ); if (!existingItem) return state; return { items: state.items.filter((item) => item.id !== productId), totalItems: state.totalItems - existingItem.quantity, totalPrice: state.totalPrice - existingItem.price * existingItem.quantity, }; }), updateQuantity: (productId, quantity) => set((state) => { const existingItem = state.items.find( (item) => item.id === productId ); if (!existingItem) return state; // Calculate the difference in quantity const quantityDiff = quantity - existingItem.quantity; const updatedItems = state.items.map((item) => item.id === productId ? { ...item, quantity } : item ); return { items: updatedItems, totalItems: state.totalItems + quantityDiff, totalPrice: state.totalPrice + existingItem.price * quantityDiff, }; }), clearCart: () => set({ items: [], totalItems: 0, totalPrice: 0, }), })); export default useCartStore; ``` ## Best Practices ### 1. Store Organization - **Single Responsibility**: Each store should focus on a specific domain - **Atomic Stores**: Keep stores small and focused - **Naming Convention**: Use `use*Store` naming for consistency - **Action Naming**: Use clear action names (add*, remove*, update*, set*, toggle\*, etc.) ### 2. Performance Optimization - **Selective State Subscription**: Extract only what you need from the store - **Memoization**: Use memoized selectors for derived state - **Shallow Equality**: Zustand uses shallow equality by default - **Avoid Large Stores**: Split large stores into smaller domain-specific ones ```javascript // BAD: Will re-render on ANY state change const { users, posts, comments } = useStore(); // GOOD: Will only re-render when these specific values change const users = useStore((state) => state.users); const posts = useStore((state) => state.posts); ``` ### 3. Combining with React Hooks ```javascript // Custom hook combining Zustand with React's useState import { useState, useEffect } from 'react'; import useAuthStore from '../stores/useAuthStore'; export function useLoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [errors, setErrors] = useState({}); const { login, isLoading, error } = useAuthStore(); // Reset errors when form changes useEffect(() => { setErrors({}); }, [email, password]); // Sync with store errors useEffect(() => { if (error) { setErrors({ form: error }); } }, [error]); const validate = () => { const newErrors = {}; if (!email) newErrors.email = 'Email is required'; if (!password) newErrors.password = 'Password is required'; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = async (e) => { e.preventDefault(); if (!validate()) return; try { await login(email, password); // Clear form on success setEmail(''); setPassword(''); } catch (err) { // Login errors handled in the store } }; return { email, setEmail, password, setPassword, errors, isLoading, handleSubmit, }; } ``` ### 4. Testing Zustand Stores ```javascript // tests/todoStore.test.js import { beforeEach, describe, expect, it } from 'vitest'; import useTodoStore from '../src/stores/useTodoStore'; // Reset the store before each test beforeEach(() => { useTodoStore.setState({ todos: [], isLoading: false, error: null, }); }); describe('Todo Store', () => { it('should add a todo', () => { // Get initial state expect(useTodoStore.getState().todos).toEqual([]); // Call the action useTodoStore.getState().addTodo({ id: '1', title: 'Test Todo', completed: false, }); // Assert the new state expect(useTodoStore.getState().todos).toEqual([ { id: '1', title: 'Test Todo', completed: false, }, ]); }); it('should toggle a todo', () => { // Setup useTodoStore.setState({ todos: [ { id: '1', title: 'Test Todo', completed: false, }, ], }); // Action useTodoStore.getState().toggleTodo('1'); // Assert expect(useTodoStore.getState().todos[0].completed).toBe(true); }); it('should handle errors during fetch', async () => { // Mock fetch to throw an error global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); // Action await useTodoStore.getState().fetchTodos(); // Assert expect(useTodoStore.getState().error).toBe('Network error'); expect(useTodoStore.getState().isLoading).toBe(false); }); }); ``` ## When to Choose Zustand Zustand is an excellent choice when: - You want a minimal, hook-based state management solution - You need something simpler than Redux but more powerful than Context API - You prefer direct store access without providers - Performance and bundle size are concerns - You value minimal boilerplate and API simplicity Zustand may not be the best fit when: - You need a more established ecosystem with extensive middleware and patterns - You require strict enforcement of unidirectional data flow - Your team is already familiar and productive with Redux - You need extensive time-travel debugging capabilities ## Comparison with Other Libraries ### Zustand vs. Redux - **API**: Zustand has a simpler, hook-based API with less boilerplate - **Bundle Size**: Zustand is much smaller (2KB vs Redux + React-Redux at 10KB+) - **Provider Requirement**: Zustand doesn't need providers - **Middleware**: Both support middleware, but Redux has more options - **Learning Curve**: Zustand is easier to learn ### Zustand vs. Context API - **Performance**: Zustand has better performance for frequent updates - **Boilerplate**: Zustand requires less code than Context + useReducer - **Provider Nesting**: No provider hell with Zustand - **Features**: Zustand offers more features (middleware, devtools, etc.) ### Zustand vs. MobX - **Paradigm**: Zustand is more functional, MobX is more object-oriented - **Learning Curve**: Zustand is simpler to learn - **Reactivity Model**: MobX uses observables, Zustand uses subscriptions - **Bundle Size**: Zustand is smaller - **Flexibility**: Both are flexible, but Zustand is more explicit