State Management and Shopify APIs Advanced 12 min read

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

src/stores/cart.ts
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

src/stores/cart.ts
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 functions
function 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

src/components/cart/QuantitySelector.tsx
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>
);
}
src/components/cart/QuantitySelector.module.css
.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

src/components/cart/RemoveButton.tsx
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

src/components/ui/Toast/Toast.tsx
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

src/components/cart/CartError.tsx
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:

src/stores/cart.ts
import { debounce } from '@/utils/debounce';
// Store pending quantity changes
const pendingQuantityChanges = new Map<string, number>();
// Debounced function to send update
const 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:

src/utils/retry.ts
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

  1. Update UI first: Don’t wait for the server—update optimistically
  2. Save previous state: Keep a reference for rollback on failure
  3. Track pending operations: Show subtle loading indicators per item
  4. Rollback gracefully: Restore previous state and show error message
  5. Debounce rapid updates: Prevent API spam from quick clicks
  6. Retry transient failures: Network hiccups shouldn’t break the experience
  7. 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...