Cart State Hell: Tại sao giỏ hàng của bạn bị lệch giữa tab, device và server
Giỏ hàng tưởng đơn giản nhưng lại là một trong những module phức tạp nhất trong ecommerce frontend. Bài viết này đi sâu vào các vấn đề thực tế: guest vs auth cart, optimistic update, race condition và multi-tab sync.
Cart State Hell: Tại sao giỏ hàng của bạn bị lệch giữa tab, device và server
Bạn đã bao giờ thêm sản phẩm vào giỏ hàng, mở tab mới và thấy giỏ hàng trống không? Hay click "+" liên tục và thấy số lượng nhảy lung tung? Đây không phải bug nhỏ — đây là triệu chứng của cart state management bị thiếu sót ở tầng kiến trúc.
Vấn đề 1: Guest Cart vs Authenticated Cart
Khi user chưa đăng nhập, cart thường lưu ở localStorage. Khi họ login, bạn phải merge cart đó vào server cart. Đây là điểm dễ gây mất dữ liệu nhất.
// lib/cart/merge.ts
export async function mergeGuestCartOnLogin(userId: string) {
const guestCart = JSON.parse(localStorage.getItem("guest_cart") ?? "[]") as CartItem[];
if (guestCart.length === 0) return;
// Lấy server cart hiện tại
const serverCart = await fetchCart(userId);
// Merge: nếu item đã có, cộng số lượng; nếu chưa, thêm mới
const merged = [...serverCart];
for (const guestItem of guestCart) {
const existing = merged.find((i) => i.productId === guestItem.productId);
if (existing) {
existing.quantity = Math.min(existing.quantity + guestItem.quantity, MAX_QUANTITY);
} else {
merged.push(guestItem);
}
}
await updateServerCart(userId, merged);
localStorage.removeItem("guest_cart");
}Quan trọng: Luôn gọi merge ngay sau khi auth callback thành công, không phải khi component mount. Nếu gọi muộn, user có thể thấy cart cũ trước khi merge xong.
Vấn đề 2: Optimistic Update với Rollback
Đừng đợi server trả về rồi mới update UI — điều này tạo cảm giác lag. Thay vào đó, update UI ngay lập tức, rồi rollback nếu server fail.
// hooks/useCart.ts
export function useCartItem(productId: string) {
const queryClient = useQueryClient();
const updateQuantity = useMutation({
mutationFn: (quantity: number) => updateCartItemApi(productId, quantity),
onMutate: async (newQuantity) => {
// Cancel inflight queries để tránh overwrite
await queryClient.cancelQueries({ queryKey: ["cart"] });
// Snapshot state hiện tại để rollback
const previousCart = queryClient.getQueryData<Cart>(["cart"]);
// Optimistic update
queryClient.setQueryData<Cart>(["cart"], (old) => ({
...old!,
items: old!.items.map((item) =>
item.productId === productId ? { ...item, quantity: newQuantity } : item
),
}));
return { previousCart };
},
onError: (_err, _newQty, context) => {
// Rollback về state cũ
queryClient.setQueryData(["cart"], context?.previousCart);
toast.error("Không thể cập nhật giỏ hàng. Vui lòng thử lại.");
},
onSettled: () => {
// Luôn refetch để đảm bảo sync với server
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
return { updateQuantity };
}Vấn đề 3: Race Condition khi Click Nhanh
User click "+" 5 lần nhanh sẽ trigger 5 API calls song song. Nếu response về không theo thứ tự, quantity cuối cùng có thể sai.
// Sai: mỗi click gửi 1 request riêng
const handleIncrement = () => {
updateQuantityApi(item.productId, item.quantity + 1); // ❌ race condition
};
// Đúng: debounce + optimistic local state
const pendingQuantityRef = useRef(item.quantity);
const handleIncrement = useCallback(() => {
pendingQuantityRef.current += 1;
setLocalQuantity(pendingQuantityRef.current); // update UI ngay
debouncedSync(pendingQuantityRef.current); // chỉ gọi API sau 500ms không có input mới
}, [debouncedSync]);
const debouncedSync = useMemo(
() =>
debounce((quantity: number) => {
updateQuantity.mutate(quantity);
}, 500),
[updateQuantity]
);Vấn đề 4: Multi-Tab Sync với BroadcastChannel
Khi user mở 2 tab, cập nhật cart ở tab 1 không tự phản ánh sang tab 2 nếu bạn chỉ dùng React state thuần.
// lib/cart/broadcast.ts
const CART_CHANNEL = "cart_sync";
export function setupCartBroadcast(onUpdate: (cart: Cart) => void) {
const channel = new BroadcastChannel(CART_CHANNEL);
channel.onmessage = (event) => {
if (event.data.type === "CART_UPDATED") {
onUpdate(event.data.cart);
}
};
return {
broadcast: (cart: Cart) => {
channel.postMessage({ type: "CART_UPDATED", cart });
},
cleanup: () => channel.close(),
};
}
// Trong cart hook
useEffect(() => {
const { broadcast, cleanup } = setupCartBroadcast((remoteCart) => {
queryClient.setQueryData(["cart"], remoteCart);
});
// Broadcast mỗi khi cart thay đổi thành công
if (cart) broadcast(cart);
return cleanup;
}, [cart, queryClient]);Lưu ý: BroadcastChannel chỉ hoạt động trong cùng origin và không hỗ trợ cross-device. Để sync cross-device, cần WebSocket hoặc SSE.
Key Takeaways
- Guest → Auth merge: thực hiện ngay sau login callback, không delay
- Optimistic update: luôn có rollback khi server fail + invalidate sau settle
- Race condition: debounce user input, gửi 1 request với giá trị cuối cùng
- Multi-tab sync: BroadcastChannel API — đơn giản, không cần external lib
- Server là source of truth: localStorage/state chỉ là cache, luôn invalidate sau mutation
If you found this helpful, leave a like!
Comments available after DB is connected.