Optimistic Updates and Error Handling
Make your cart feel instant with optimistic updates. Learn to update UI immediately, then sync with the server, rolling back on errors.
Waiting for server responses makes your UI feel sluggish. Optimistic updates solve this by updating the UI immediately, assuming success, and rolling back if the server request fails.
The Problem with Waiting
Without optimistic updates, clicking ”+” on a quantity takes 200-500ms to respond:
User clicks + │ ▼┌─────────────────┐│ Show loading │ ← User waits...│ spinner │└─────────────────┘ │ 200-500ms ▼┌─────────────────┐│ Server responds │└─────────────────┘ │ ▼┌─────────────────┐│ Update UI │ ← Finally!└─────────────────┘The Optimistic Solution
With optimistic updates, the UI updates instantly:
User clicks + │ ├──────────────────────────────────────┐ ▼ ▼┌─────────────────┐ ┌─────────────────┐│ Update UI │ ← Instant! │ Send request ││ immediately │ │ to server │└─────────────────┘ └─────────────────┘ │ Success │ Failure │ │ ▼ ▼ ┌──────────┐ ┌──────────┐ │ Done! │ │ Rollback │ │ │ │ UI │ └──────────┘ └──────────┘Implementing Optimistic Updates
Basic Pattern
import { create } from 'zustand';import type { Cart } from '@/types';import * as cartApi from '@/api/cart';
/* * Optimistic Updates Pattern * * The key insight: Update the UI immediately, THEN sync with server. * If server fails, rollback to previous state. * * This makes the app feel instant instead of waiting 200-500ms for every action. */
interface CartState { cart: Cart | null; isLoading: boolean; error: string | null;
updateItemOptimistic: (key: string, quantity: number) => Promise<void>;}
export const useCart = create<CartState>((set, get) => ({ cart: null, isLoading: false, error: null,
updateItemOptimistic: async (key, quantity) => { // STEP 0: Save current state for potential rollback const previousCart = get().cart;
// STEP 1: Optimistically update the UI (instant feedback!) set((state) => ({ cart: state.cart ? updateCartItem(state.cart, key, quantity) : null, error: null, // Clear any previous errors }));
try { // STEP 2: Send request to server (happens in background) const serverCart = await cartApi.updateLineItem(key, quantity);
// STEP 3: Replace optimistic data with server response // Server is the source of truth (handles edge cases, discounts, etc.) set({ cart: transformShopifyCart(serverCart) }); } catch (error) { // STEP 4: Something went wrong - rollback to saved state set({ cart: previousCart, error: 'Failed to update cart. Please try again.', }); } },}));
/** * Updates a cart item's quantity locally (no API call) * This is what makes the UI feel instant */function updateCartItem(cart: Cart, key: string, quantity: number): Cart { // Remove item if quantity is 0, otherwise update quantity const items = quantity === 0 ? cart.items.filter(item => item.key !== key) : cart.items.map(item => item.key === key ? { ...item, quantity, linePrice: item.price * quantity } : item );
// Recalculate totals locally const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); const totalPrice = items.reduce((sum, item) => sum + item.linePrice, 0);
// Return new cart object (immutable update) return { ...cart, items, itemCount, totalPrice, };}Complete Cart Store with Optimistic Updates
import { create } from 'zustand';import type { Cart, CartLineItem } from '@/types';import * as cartApi from '@/api/cart';
interface CartState { cart: Cart | null; pendingUpdates: Set<string>; error: string | null; isOpen: boolean;
// Actions addItem: (variantId: number, quantity: number) => Promise<void>; updateItem: (key: string, quantity: number) => Promise<void>; removeItem: (key: string) => Promise<void>;
// UI openCart: () => void; closeCart: () => void; clearError: () => void;
// Helpers isItemPending: (key: string) => boolean;}
export const useCart = create<CartState>((set, get) => ({ cart: getInitialCart(), pendingUpdates: new Set(), error: null, isOpen: false,
addItem: async (variantId, quantity) => { // For add, we don't have a line item yet, so we show loading const tempKey = `temp-${Date.now()}`;
set((state) => ({ pendingUpdates: new Set(state.pendingUpdates).add(tempKey), error: null, }));
try { const serverCart = await cartApi.addToCart(variantId, quantity); set({ cart: transformShopifyCart(serverCart), isOpen: true, }); } catch (error) { set({ error: 'Failed to add item to cart' }); throw error; } finally { set((state) => { const pending = new Set(state.pendingUpdates); pending.delete(tempKey); return { pendingUpdates: pending }; }); } },
updateItem: async (key, quantity) => { const previousCart = get().cart;
// Mark as pending set((state) => ({ pendingUpdates: new Set(state.pendingUpdates).add(key), error: null, }));
// Optimistic update set((state) => ({ cart: state.cart ? optimisticUpdateItem(state.cart, key, quantity) : null, }));
try { const serverCart = await cartApi.updateLineItem(key, quantity); set({ cart: transformShopifyCart(serverCart) }); } catch (error) { // Rollback set({ cart: previousCart, error: 'Failed to update quantity', }); } finally { set((state) => { const pending = new Set(state.pendingUpdates); pending.delete(key); return { pendingUpdates: pending }; }); } },
removeItem: async (key) => { const previousCart = get().cart;
set((state) => ({ pendingUpdates: new Set(state.pendingUpdates).add(key), error: null, }));
// Optimistic removal set((state) => ({ cart: state.cart ? optimisticRemoveItem(state.cart, key) : null, }));
try { const serverCart = await cartApi.removeLineItem(key); set({ cart: transformShopifyCart(serverCart) }); } catch (error) { set({ cart: previousCart, error: 'Failed to remove item', }); } finally { set((state) => { const pending = new Set(state.pendingUpdates); pending.delete(key); return { pendingUpdates: pending }; }); } },
openCart: () => set({ isOpen: true }), closeCart: () => set({ isOpen: false }), clearError: () => set({ error: null }),
isItemPending: (key) => get().pendingUpdates.has(key),}));
// Helper functionsfunction optimisticUpdateItem(cart: Cart, key: string, quantity: number): Cart { const items = cart.items.map(item => { if (item.key !== key) return item;
return { ...item, quantity, linePrice: item.price * quantity, finalLinePrice: item.price * quantity, // Simplified, ignores discounts }; });
return recalculateCart(cart, items);}
function optimisticRemoveItem(cart: Cart, key: string): Cart { const items = cart.items.filter(item => item.key !== key); return recalculateCart(cart, items);}
function recalculateCart(cart: Cart, items: CartLineItem[]): Cart { const itemCount = items.reduce((sum, item) => sum + item.quantity, 0); const totalPrice = items.reduce((sum, item) => sum + item.finalLinePrice, 0);
return { ...cart, items, itemCount, totalPrice, };}UI Components with Optimistic Updates
Quantity Selector
import { useCart } from '@/stores/cart';import styles from './QuantitySelector.module.css';
interface QuantitySelectorProps { itemKey: string; quantity: number;}
export function QuantitySelector({ itemKey, quantity }: QuantitySelectorProps) { const updateItem = useCart((state) => state.updateItem); const isPending = useCart((state) => state.isItemPending(itemKey));
const handleDecrease = () => { if (quantity > 1) { updateItem(itemKey, quantity - 1); } };
const handleIncrease = () => { updateItem(itemKey, quantity + 1); };
return ( <div className={`${styles.selector} ${isPending ? styles.pending : ''}`}> <button onClick={handleDecrease} disabled={quantity <= 1} aria-label="Decrease quantity" > − </button>
<span className={styles.quantity}> {quantity} </span>
<button onClick={handleIncrease} aria-label="Increase quantity" > + </button> </div> );}.selector { display: flex; align-items: center; gap: 0.5rem; transition: opacity 0.15s ease;}
.pending { opacity: 0.6; pointer-events: none;}
.quantity { min-width: 2rem; text-align: center;}Remove Button with Animation
import { useCart } from '@/stores/cart';
interface RemoveButtonProps { itemKey: string;}
export function RemoveButton({ itemKey }: RemoveButtonProps) { const removeItem = useCart((state) => state.removeItem); const isPending = useCart((state) => state.isItemPending(itemKey));
return ( <button onClick={() => removeItem(itemKey)} disabled={isPending} className="remove-button" aria-label="Remove item" > {isPending ? 'Removing...' : 'Remove'} </button> );}Error Handling Patterns
Toast Notifications
import { useEffect } from 'react';import styles from './Toast.module.css';
interface ToastProps { message: string; type: 'error' | 'success' | 'info'; onDismiss: () => void; duration?: number;}
export function Toast({ message, type, onDismiss, duration = 5000}: ToastProps) { useEffect(() => { const timer = setTimeout(onDismiss, duration); return () => clearTimeout(timer); }, [onDismiss, duration]);
return ( <div className={`${styles.toast} ${styles[type]}`} role="alert"> <span>{message}</span> <button onClick={onDismiss} aria-label="Dismiss">×</button> </div> );}Cart Error Display
import { useCart } from '@/stores/cart';import { Toast } from '@/components/ui';
export function CartError() { const error = useCart((state) => state.error); const clearError = useCart((state) => state.clearError);
if (!error) return null;
return ( <Toast message={error} type="error" onDismiss={clearError} /> );}Debouncing Rapid Updates
Prevent excessive API calls when users click rapidly:
import { debounce } from '@/utils/debounce';
// Store pending quantity changesconst pendingQuantityChanges = new Map<string, number>();
// Debounced function to send updateconst debouncedUpdate = debounce(async ( key: string, quantity: number, set: (partial: Partial<CartState>) => void, previousCart: Cart | null) => { try { const serverCart = await cartApi.updateLineItem(key, quantity); set({ cart: transformShopifyCart(serverCart) }); } catch (error) { set({ cart: previousCart, error: 'Failed to update quantity', }); } finally { pendingQuantityChanges.delete(key); set((state) => { const pending = new Set(state.pendingUpdates); pending.delete(key); return { pendingUpdates: pending }; }); }}, 500);
export const useCart = create<CartState>((set, get) => ({ // ...
updateItem: async (key, quantity) => { const previousCart = get().cart;
// Store the latest quantity pendingQuantityChanges.set(key, quantity);
// Optimistic update (immediate) set((state) => ({ cart: state.cart ? optimisticUpdateItem(state.cart, key, quantity) : null, pendingUpdates: new Set(state.pendingUpdates).add(key), error: null, }));
// Debounced server update debouncedUpdate(key, quantity, set, previousCart); },}));Retry Logic
Add automatic retry for transient failures:
interface RetryOptions { maxAttempts: number; delayMs: number; backoff?: boolean;}
export async function withRetry<T>( operation: () => Promise<T>, options: RetryOptions): Promise<T> { const { maxAttempts, delayMs, backoff = true } = options;
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await operation(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error));
if (attempt < maxAttempts) { const delay = backoff ? delayMs * attempt : delayMs; await new Promise(resolve => setTimeout(resolve, delay)); } } }
throw lastError;}Using retry in the cart store:
updateItem: async (key, quantity) => { const previousCart = get().cart;
set((state) => ({ cart: state.cart ? optimisticUpdateItem(state.cart, key, quantity) : null, pendingUpdates: new Set(state.pendingUpdates).add(key), error: null, }));
try { const serverCart = await withRetry( () => cartApi.updateLineItem(key, quantity), { maxAttempts: 3, delayMs: 1000 } ); set({ cart: transformShopifyCart(serverCart) }); } catch (error) { set({ cart: previousCart, error: 'Failed to update after multiple attempts', }); } finally { // ... cleanup }},Key Takeaways
- Update UI first: Don’t wait for the server—update optimistically
- Save previous state: Keep a reference for rollback on failure
- Track pending operations: Show subtle loading indicators per item
- Rollback gracefully: Restore previous state and show error message
- Debounce rapid updates: Prevent API spam from quick clicks
- Retry transient failures: Network hiccups shouldn’t break the experience
- Recalculate totals locally: Keep item count and totals in sync optimistically
In the next lesson, we’ll apply these patterns to product state—variants, availability, and selection.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...