React State Management: Managing Cart & Inventory
As your ecommerce store grows—from 100 products to 100,000—a single Zustand store for the cart becomes insufficient. You need a robust state architecture that tracks inventory in real time, syncs with backend APIs, handles race conditions, and scales without prop drilling or Redux boilerplate. This article shows you how to design multiple stores for different concerns (cart, inventory, user session) and how to sync them efficiently with your backend. You'll learn patterns used by production stores like Shopify and Amazon: optimistic updates, cache invalidation, and conflict resolution.
Design Multiple Stores for Separation of Concerns
Instead of one monolithic store, create specialized stores for different domains:
Cart Store (src/store/cartStore.js): Items the user selected, quantities, local totals.
Inventory Store (src/store/inventoryStore.js): Real-time stock levels, availability, backorder status.
User Store (src/store/userStore.js): Authentication, session, saved addresses, payment methods.
Each store is independent and focused. Here's the inventory store:
import { create } from 'zustand';
export const useInventoryStore = create((set, get) => ({
inventory: {}, // { productId: { stock, reserved, available } }
syncing: false,
lastSync: null,
// Fetch inventory from backend
fetchInventory: async (productIds) => {
set({ syncing: true });
try {
const response = await fetch(
`${import.meta.env.VITE_API_URL}/inventory?ids=${productIds.join(',')}`
);
const data = await response.json();
set({
inventory: data,
lastSync: new Date(),
syncing: false,
});
} catch (error) {
console.error('Failed to sync inventory:', error);
set({ syncing: false });
}
},
// Check if product is available
isAvailable: (productId, quantity = 1) => {
const state = get();
const stock = state.inventory[productId];
return stock && stock.available >= quantity;
},
// Reserve items (before purchase)
reserve: (productId, quantity) =>
set((state) => ({
inventory: {
...state.inventory,
[productId]: {
...state.inventory[productId],
reserved: (state.inventory[productId]?.reserved || 0) + quantity,
available: (state.inventory[productId]?.available || 0) - quantity,
},
},
})),
// Release reservation (on cancel)
release: (productId, quantity) =>
set((state) => ({
inventory: {
...state.inventory,
[productId]: {
...state.inventory[productId],
reserved: Math.max(0, (state.inventory[productId]?.reserved || 0) - quantity),
available: (state.inventory[productId]?.available || 0) + quantity,
},
},
})),
}));
This store tracks real-time inventory: total stock, reserved items (in someone's cart), and available items (stock minus reservations). The reserve() method prevents overselling by reducing available stock when items are added to a cart.
Sync Stores When the App Mounts
Create a custom hook to initialize all stores and fetch fresh data:
// src/hooks/useInitializeStores.js
import { useEffect } from 'react';
import { useCartStore } from '../store/cartStore';
import { useInventoryStore } from '../store/inventoryStore';
import { useUserStore } from '../store/userStore';
export function useInitializeStores() {
const cartItems = useCartStore((state) => state.items);
const fetchInventory = useInventoryStore((state) => state.fetchInventory);
const fetchUser = useUserStore((state) => state.fetchUser);
useEffect(() => {
// Fetch user session
fetchUser();
// Fetch inventory for products in cart
if (cartItems.length > 0) {
const productIds = cartItems.map((item) => item.id);
fetchInventory(productIds);
}
// Periodically refresh inventory (every 30 seconds)
const interval = setInterval(() => {
if (cartItems.length > 0) {
const productIds = cartItems.map((item) => item.id);
fetchInventory(productIds);
}
}, 30000);
return () => clearInterval(interval);
}, [cartItems, fetchInventory, fetchUser]);
}
Call this hook in your root App.jsx:
import { useInitializeStores } from './hooks/useInitializeStores';
export default function App() {
useInitializeStores();
return (
// ... rest of app
);
}
Implement Optimistic Updates
When a user adds an item to their cart, update the UI immediately, then confirm with the backend. If the backend fails, revert:
export const useCartStore = create((set) => ({
items: [],
pendingUpdates: [],
addItemOptimistic: async (product, quantity = 1) => {
// Optimistic update: add to cart immediately
set((state) => ({
items: [...state.items, { ...product, quantity, id: `temp_${Date.now()}` }],
pendingUpdates: [...state.pendingUpdates, { productId: product.id, quantity }],
}));
// Sync with backend
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/cart/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: product.id, quantity }),
});
if (!response.ok) throw new Error('Failed to add to cart');
// Backend confirmed; update with real ID
const data = await response.json();
set((state) => ({
items: state.items.map((item) =>
item.id === `temp_${Date.now()}` ? data.cartItem : item
),
pendingUpdates: state.pendingUpdates.filter(
(u) => u.productId !== product.id
),
}));
} catch (error) {
// Revert on failure
set((state) => ({
items: state.items.filter((item) => item.id !== `temp_${Date.now()}`),
pendingUpdates: state.pendingUpdates.filter(
(u) => u.productId !== product.id
),
}));
console.error('Failed to add item:', error);
}
},
}));
This pattern is called "optimistic updates": assume the operation will succeed, update the UI immediately, then verify with the backend. If it fails, revert. Users feel no lag.
Handle Race Conditions with Request IDs
When multiple API requests are in flight, ensure responses are applied in the correct order using request IDs:
export const useInventoryStore = create((set) => ({
inventory: {},
lastRequestId: null,
fetchInventory: async (productIds) => {
const requestId = Date.now();
set({ lastRequestId: requestId });
const response = await fetch(
`${import.meta.env.VITE_API_URL}/inventory?ids=${productIds.join(',')}`
);
const data = await response.json();
// Only apply if this is the most recent request
set((state) => {
if (state.lastRequestId !== requestId) return state;
return { inventory: data, lastRequestId: requestId };
});
},
}));
If the user filters products (Request A), then immediately filters again (Request B), only Request B's result is applied. This prevents stale data from overwriting fresh data.
Comparison: Zustand vs Redux vs Context
| Aspect | Zustand | Redux | Context |
|---|---|---|---|
| Boilerplate | Minimal (5–10 lines per store) | High (actions, reducers, types) | Minimal but prop drilling |
| Performance | Excellent (selective subscriptions) | Excellent (normalization) | Slow (re-renders entire tree) |
| DevTools | Good | Excellent (time-travel debugging) | None |
| Bundle Size | 2 KB | 10 KB | 0 KB (built-in) |
| Learning Curve | Gentle | Steep | Gentle |
| Best For | Most projects 2026+ | Complex teams needing DevTools | Simple local state |
Zustand is recommended for ecommerce in 2026 because it scales without Redux's verbosity, and it performs better than Context for frequent updates (like cart/inventory changes).
Key Takeaways
- Separate your state into multiple focused stores (cart, inventory, user) instead of one monolithic store.
- Inventory reservations prevent overselling: reduce available stock when items enter a cart.
- Optimistic updates make the UI responsive—update immediately, sync with backend, revert on failure.
- Use request IDs to prevent race conditions when multiple API calls are in flight.
- Periodically refresh inventory from the backend (every 30 seconds) to catch out-of-stock changes.
Frequently Asked Questions
What happens if inventory goes negative?
Your backend should validate that reserved items don't exceed stock. If they do, the checkout request fails and you tell the user "Item is no longer available." Always enforce constraints on the server, never trust the frontend.
How do I handle concurrent cart updates from multiple devices?
Implement "last-write-wins" by including a version number or updatedAt timestamp with each cart. The backend merges changes: newer timestamps win. Alternatively, sync carts to the backend immediately instead of using localStorage.
Should I revalidate inventory before accepting a payment?
Absolutely. In the checkout flow, refetch inventory from the backend and verify all items are still available at the final price. This is your last chance to catch stock changes.
Can I use Zustand with React Query for server state?
Yes, and it's recommended. Use Zustand for client state (cart, UI filters) and React Query for server state (products, inventory). This separation is cleaner and scales better.