Building a Complex E-commerce Application with Zustand

Building a Complex E-commerce Application with Zustand presents unique state management challenges. This lesson explores how Zustand can be effectively utilized in such a scenario, focusing on key aspects like managing product catalogs, shopping carts, user authentication, and order processing. We'll delve into practical examples and strategies for optimizing performance and maintaining a clean, scalable codebase.
E-commerce Application State Requirements
An e-commerce application typically involves managing a diverse range of state, including:
Product Catalog: Information about available products, including details like name, description, price, images, and inventory.
Shopping Cart: The items a user intends to purchase, along with quantities and calculated totals.
User Authentication: User login status, profile information, and authentication tokens.
Order Processing: Information related to placing and tracking orders, including shipping addresses, payment details, and order history.
UI State: Various UI-related states, such as filters, sorting options, search queries, and modal visibility.
Zustand's simplicity and flexibility make it well-suited for managing these diverse state requirements.
Structuring Zustand Stores for E-commerce
A well-structured Zustand store is crucial for maintaining a manageable and scalable e-commerce application. Consider breaking down the application state into multiple stores, each responsible for a specific domain.
Product Catalog Store
This store manages the product catalog data.
import { create } from 'zustand';
const useProductCatalogStore = create((set, get) => ({
products: [],
isLoading: false,
error: null,
fetchProducts: async () => {
set({ isLoading: true, error: null });
try {
const response = await fetch('/api/products'); // Replace with your API endpoint
const data = await response.json();
set({ products: data, isLoading: false });
} catch (error) {
set({ error: error.message, isLoading: false });
}
},
getProductById: (id) => get().products.find((product) => product.id === id),
}));
export default useProductCatalogStore;
Explanation:
products
: An array to store the product data.isLoading
: A boolean to indicate whether the product data is being fetched.error
: A string to store any error message that occurs during the fetch.fetchProducts
: An asynchronous function to fetch the product data from an API endpoint.getProductById
: A selector function to retrieve a product by its ID.
Shopping Cart Store
This store manages the shopping cart data.
import { create } from 'zustand';
const useShoppingCartStore = create((set, get) => ({
cartItems: [],
addToCart: (product, quantity) => {
set((state) => {
const existingItem = state.cartItems.find((item) => item.product.id === product.id);
if (existingItem) {
return {
cartItems: state.cartItems.map((item) =>
item.product.id === product.id ? { ...item, quantity: item.quantity + quantity } : item
),
};
} else {
return { cartItems: [...state.cartItems, { product, quantity }] };
}
});
},
removeFromCart: (productId) => {
set((state) => ({
cartItems: state.cartItems.filter((item) => item.product.id !== productId),
}));
},
updateQuantity: (productId, quantity) => {
set((state) => ({
cartItems: state.cartItems.map((item) =>
item.product.id === productId ? { ...item, quantity } : item
),
}));
},
clearCart: () => {
set({ cartItems: [] });
},
getTotalItems: () => get().cartItems.reduce((total, item) => total + item.quantity, 0),
getTotalPrice: () =>
get().cartItems.reduce((total, item) => total + item.product.price * item.quantity, 0),
}));
export default useShoppingCartStore;
Explanation:
cartItems
: An array to store the items in the shopping cart. Each item contains the product and the quantity.addToCart
: A function to add a product to the cart or update the quantity if the product already exists.removeFromCart
: A function to remove a product from the cart.updateQuantity
: A function to update the quantity of a product in the cart.clearCart
: A function to clear the entire cart.getTotalItems
: A selector function to calculate the total number of items in the cart.getTotalPrice
: A selector function to calculate the total price of the items in the cart.
User Authentication Store
This store manages the user authentication state.
import { create } from 'zustand';
const useAuthStore = create((set) => ({
user: null,
token: null,
isAuthenticated: false,
login: async (username, password) => {
// Simulate API call
const response = await fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok) {
set({ user: data.user, token: data.token, isAuthenticated: true });
localStorage.setItem('token', data.token); // Persist token
} else {
throw new Error(data.message || 'Login failed');
}
},
logout: () => {
set({ user: null, token: null, isAuthenticated: false });
localStorage.removeItem('token');
},
initializeAuth: () => {
const token = localStorage.getItem('token');
if (token) {
// Simulate fetching user data with token
fetch('/api/user', {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.json())
.then((data) => {
if (data.user) {
set({ user: data.user, token: token, isAuthenticated: true });
} else {
localStorage.removeItem('token');
}
})
.catch(() => localStorage.removeItem('token'));
}
},
}));
export default useAuthStore;
Explanation:
user
: Stores the user object when logged in.token
: Stores the authentication token.isAuthenticated
: A boolean indicating whether the user is authenticated.login
: An asynchronous function to handle user login. It simulates an API call and updates the state with user data and token. It also persists the token in local storage.logout
: A function to handle user logout, clearing the state and removing the token from local storage.initializeAuth
: A function to initialize the authentication state when the application loads. It checks for a token in local storage and fetches user data if a token exists.
Order Store
This store manages the order processing state.
import { create } from 'zustand';
const useOrderStore = create((set, get) => ({
orders: [],
currentOrder: null,
shippingAddress: null,
paymentMethod: null,
setShippingAddress: (address) => {
set({ shippingAddress: address });
},
setPaymentMethod: (method) => {
set({ paymentMethod: method });
},
createOrder: async (orderData) => {
try {
// Simulate API call to create order
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(orderData),
});
const newOrder = await response.json();
set((state) => ({
orders: [...state.orders, newOrder],
currentOrder: newOrder,
}));
return newOrder;
} catch (error) {
console.error('Error creating order:', error);
throw error;
}
},
fetchOrders: async () => {
try {
// Simulate API call to fetch orders
const response = await fetch('/api/orders');
const orders = await response.json();
set({ orders: orders });
} catch (error) {
console.error('Error fetching orders:', error);
}
},
getOrderById: (orderId) => {
return get().orders.find((order) => order.id === orderId);
},
}));
export default useOrderStore;
Explanation:
orders
: An array to store the user's order history.currentOrder
: Stores the details of the order being currently processed.shippingAddress
: Stores the shipping address for the current order.paymentMethod
: Stores the selected payment method for the current order.setShippingAddress
: Updates the shipping address in the store.setPaymentMethod
: Updates the payment method in the store.createOrder
: Simulates creating a new order via an API call and adds it to theorders
array.fetchOrders
: Simulates fetching the user's order history from an API.getOrderById
: Retrieves a specific order from theorders
array by its ID.
Optimizing Performance in E-commerce Applications
E-commerce applications often involve complex UIs and frequent data updates. Optimizing performance is crucial for providing a smooth user experience.
Selectors and Memoization
Use selectors to derive specific data from the store and memoize them to prevent unnecessary re-renders. This was covered in Module 1.
import { create } from 'zustand';
import { shallow } from 'zustand/shallow';
const useStore = create((set, get) => ({
products: [
{ id: 1, name: 'Product A', price: 20 },
{ id: 2, name: 'Product B', price: 30 },
{ id: 3, name: 'Product C', price: 40 },
],
discount: 0.1,
setDiscount: (newDiscount) => set({ discount: newDiscount }),
// Selector with memoization
expensiveCalculation: () => {
console.log('Calculating total value...'); // This should only log when products or discount change
const { products, discount } = get();
return products.reduce((total, product) => total + product.price * (1 - discount), 0);
},
}));
export default useStore;
// Usage in a component:
function MyComponent() {
const totalValue = useStore(state => state.expensiveCalculation());
const setDiscount = useStore(state => state.setDiscount);
return (
<div>
<p>Total Value: {totalValue}</p>
<button onClick={() => setDiscount(0.2)}>Apply Discount</button>
</div>
);
}
Explanation:
The
expensiveCalculation
selector calculates the total value of all products after applying a discount.By default, Zustand doesn't automatically memoize selectors. However, you can use
React.useMemo
within your component if needed for more complex memoization scenarios. Zustand'sshallow
comparison can also help prevent re-renders if the selector returns a new object each time, but the underlying data hasn't changed.
Batch Updates
Use setState
's functional updates to batch multiple state updates into a single render cycle. This was covered in Module 6.
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
doubleCount: 0,
incrementBoth: () => {
set((state) => ({
count: state.count + 1,
doubleCount: state.doubleCount + 2,
}));
},
}));
export default useStore;
Explanation:
incrementBoth
updates bothcount
anddoubleCount
in a singleset
call, preventing two separate re-renders.
Debouncing and Throttling
For UI-related state, such as search queries or filter inputs, use debouncing or throttling to limit the frequency of state updates.
import { create } from 'zustand';
import { debounce } from 'lodash'; // Or any other debouncing/throttling library
const useSearchStore = create((set) => ({
searchQuery: '',
setSearchQuery: debounce((query) => {
set({ searchQuery: query });
// Perform search logic here
console.log('Performing search with query:', query);
}, 300), // Debounce for 300ms
}));
export default useSearchStore;
Explanation:
setSearchQuery
is debounced, so it only updates thesearchQuery
state and performs the search logic after the user has stopped typing for 300ms.
Advanced Zustand Patterns for E-commerce
Persistence Middleware
Use persistence middleware to persist the shopping cart and user authentication state across sessions. This was covered in Module 5.
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useShoppingCartStore = create(
persist(
(set, get) => ({
cartItems: [],
addToCart: (product) => {
set((state) => ({ cartItems: [...state.cartItems, product] }));
},
// ... other cart actions
}),
{
name: 'shopping-cart', // Unique name for the storage key
// You can also customize the storage provider (e.g., localStorage, sessionStorage, cookies)
}
)
);
export default useShoppingCartStore;
Explanation:
- The
persist
middleware automatically saves thecartItems
to local storage and loads them when the application starts.
Zustand with Immer
For complex state updates, consider using Zustand with Immer to simplify immutable state management. This was covered in Module 1.
sudo snap install slack --classic
sudo snap install slack --classic
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
const useStore = create(immer((set) => ({
user: {
name: 'John Doe',
address: {
street: '123 Main St',
city: 'Anytown',
},
},
updateCity: (newCity) => set((state) => {
state.user.address.city = newCity; // Direct mutation with Immer
}),
})));
export default useStore;
Explanation:
- Immer allows you to directly mutate the state within the
set
function, while ensuring that the state updates are still immutable.
Zustand as a Finite State Machine
For managing order processing or other complex workflows, consider using Zustand as a finite state machine. This was covered in Module 2.
import { create } from 'zustand';
const useOrderFSM = create((set) => ({
state: 'idle', // initial state
events: {
PLACE_ORDER: () => set({ state: 'pending' }),
CONFIRM_PAYMENT: () => set({ state: 'processing' }),
SHIP_ORDER: () => set({ state: 'shipped' }),
DELIVER_ORDER: () => set({ state: 'delivered' }),
CANCEL_ORDER: () => set({ state: 'cancelled' }),
},
}));
export default useOrderFSM;
Explanation:
The store manages the state of an order through different stages (idle, pending, processing, etc.).
The
events
object defines the possible transitions between states.
Conclusion
In conclusion, building a complex e-commerce application with Zustand offers a robust and flexible approach to state management. By structuring Zustand stores to handle distinct domains such as product catalogs, shopping carts, user authentication, and order processing, developers can maintain a clean and scalable codebase. Optimizing performance through techniques like selectors, memoization, batch updates, and debouncing ensures a smooth user experience. Advanced patterns, including persistence middleware, Zustand with Immer, and using Zustand as a finite state machine, further enhance the application's capabilities. Zustand's simplicity and adaptability make it an excellent choice for managing the diverse state requirements of modern e-commerce applications.
Subscribe to my newsletter
Read articles from Satyam Garg directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
