React.js Fundamentals
State Management with Zustand
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.