DEV Community

Cover image for Browser-State Architecture: A Pattern-Driven Exploration Through Shopping Cart Implementations
pronab pal
pronab pal

Posted on

Browser-State Architecture: A Pattern-Driven Exploration Through Shopping Cart Implementations

Ever wondered about the best way to handle shopping cart state in your React application? Should you use Context? LocalStorage? Or perhaps something more robust like IndexedDB? In this article, we'll build the same shopping cart using different approaches and compare their pros and cons with real, working code.

TL;DR

  • Context is great for real-time updates but loses state on refresh
  • LocalStorage is simple but limited in size and synchronous
  • IndexedDB offers the best of both worlds with some added complexity
  • We'll build a hybrid approach that you can use in production

The Challenge

Building a shopping cart seems simple at first, but there are several requirements to consider:

  • Persist items across page refreshes
  • Handle multiple browser tabs
  • Manage large datasets efficiently
  • Provide smooth user experience
  • Work offline

Let's build the same cart using different approaches and see how they stack up.

Approach 1: React Context

First, let's look at the simplest approach using React Context:

import React, { createContext, useContext, useState } from 'react';

// Context definition
const CartContext = createContext();

const CartProvider = ({ children }) => {
  const [items, setItems] = useState([]);

  const addItem = (product) => {
    setItems(prev => {
      const existingItem = prev.find(item => item.id === product.id);
      if (existingItem) {
        return prev.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }
      return [...prev, { ...product, quantity: 1 }];
    });
  };

  const updateQuantity = (productId, quantity) => {
    if (quantity < 1) return;
    setItems(prev => 
      prev.map(item => 
        item.id === productId ? { ...item, quantity } : item
      )
    );
  };

  const removeItem = (productId) => {
    setItems(prev => prev.filter(item => item.id !== productId));
  };

  return (
    <CartContext.Provider value={{ 
      items, 
      addItem, 
      updateQuantity, 
      removeItem,
      total: items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
    }}>
      {children}
    </CartContext.Provider>
  );
};

// Sample products data
const SAMPLE_PRODUCTS = [
  { id: 1, name: 'Laptop', price: 999 },
  { id: 2, name: 'Smartphone', price: 699 },
  { id: 3, name: 'Headphones', price: 199 },
];

// Product List Component
const ProductList = () => {
  const { addItem } = useContext(CartContext);

  return (
    <div className="border p-4 rounded-lg">
      <h2 className="text-xl font-bold mb-4">Products</h2>
      <div className="space-y-4">
        {SAMPLE_PRODUCTS.map(product => (
          <div key={product.id} className="flex justify-between items-center p-2 border rounded">
            <div>
              <h3 className="font-semibold">{product.name}</h3>
              <p className="text-gray-600">${product.price}</p>
            </div>
            <button
              onClick={() => addItem(product)}
              className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
            >
              Add to Cart
            </button>
          </div>
        ))}
      </div>
    </div>
  );
};

// Shopping Cart Component
const ShoppingCart = () => {
  const { items, updateQuantity, removeItem, total } = useContext(CartContext);

  return (
    <div className="border p-4 rounded-lg">
      <h2 className="text-xl font-bold mb-4">Shopping Cart</h2>
      {items.length === 0 ? (
        <p className="text-gray-500">Your cart is empty</p>
      ) : (
        <div className="space-y-4">
          {items.map(item => (
            <div key={item.id} className="flex justify-between items-center p-2 border rounded">
              <div>
                <h3 className="font-semibold">{item.name}</h3>
                <p className="text-gray-600">${item.price}</p>
              </div>
              <div className="flex items-center space-x-2">
                <input
                  type="number"
                  min="1"
                  value={item.quantity}
                  onChange={e => updateQuantity(item.id, parseInt(e.target.value))}
                  className="w-16 p-1 border rounded"
                />
                <button
                  onClick={() => removeItem(item.id)}
                  className="text-red-500 hover:text-red-600"
                >
                  Remove
                </button>
              </div>
            </div>
          ))}
          <div className="pt-4 border-t">
            <p className="text-xl font-bold">Total: ${total.toFixed(2)}</p>
          </div>
        </div>
      )}
    </div>
  );
};

// Main App Component
const App = () => {
  return (
    <CartProvider>
      <div className="p-4 max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold mb-6">Shopping Cart Demo</h1>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          <ProductList />
          <ShoppingCart />
        </div>
      </div>
    </CartProvider>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

### Pros of Context Approach
✅ Simple to implement
✅ Real-time updates
✅ No extra dependencies
✅ Perfect for small applications

### Cons of Context Approach
❌ Loses state on page refresh
❌ No cross-tab synchronization
❌ Memory-only storage
❌ Not suitable for large datasets

Enter fullscreen mode Exit fullscreen mode

Approach 2: LocalStorage with Context

Let's enhance our Context approach with LocalStorage persistence:

 import React, { createContext, useContext, useState, useEffect } from 'react';

const CartContext = createContext();
const STORAGE_KEY = 'shopping-cart';

const CartProvider = ({ children }) => {
  const [items, setItems] = useState(() => {
    if (typeof window === 'undefined') return [];
    const stored = localStorage.getItem(STORAGE_KEY);
    return stored ? JSON.parse(stored) : [];
  });

  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
  }, [items]);

  const addItem = (product) => {
    setItems(prev => {
      const existingItem = prev.find(item => item.id === product.id);
      if (existingItem) {
        return prev.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }
      return [...prev, { ...product, quantity: 1 }];
    });
  };

  const updateQuantity = (productId, quantity) => {
    if (quantity < 1) return;
    setItems(prev => 
      prev.map(item => 
        item.id === productId ? { ...item, quantity } : item
      )
    );
  };

  const removeItem = (productId) => {
    setItems(prev => prev.filter(item => item.id !== productId));
  };

  const clearCart = () => {
    setItems([]);
    localStorage.removeItem(STORAGE_KEY);
  };

  return (
    <CartContext.Provider value={{ 
      items, 
      addItem, 
      updateQuantity, 
      removeItem,
      clearCart,
      total: items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
    }}>
      {children}
    </CartContext.Provider>
  );
};

// Sample products data
const SAMPLE_PRODUCTS = [
  { id: 1, name: 'Laptop', price: 999 },
  { id: 2, name: 'Smartphone', price: 699 },
  { id: 3, name: 'Headphones', price: 199 },
];

// Product List Component
const ProductList = () => {
  const { addItem } = useContext(CartContext);

  return (
    <div className="border p-4 rounded-lg">
      <h2 className="text-xl font-bold mb-4">Products</h2>
      <div className="space-y-4">
        {SAMPLE_PRODUCTS.map(product => (
          <div key={product.id} className="flex justify-between items-center p-2 border rounded">
            <div>
              <h3 className="font-semibold">{product.name}</h3>
              <p className="text-gray-600">${product.price}</p>
            </div>
            <button
              onClick={() => addItem(product)}
              className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
            >
              Add to Cart
            </button>
          </div>
        ))}
      </div>
    </div>
  );
};

// Shopping Cart Component
const ShoppingCart = () => {
  const { items, updateQuantity, removeItem, clearCart, total } = useContext(CartContext);

  return (
    <div className="border p-4 rounded-lg">
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-xl font-bold">Shopping Cart</h2>
        {items.length > 0 && (
          <button
            onClick={clearCart}
            className="text-red-500 hover:text-red-600"
          >
            Clear Cart
          </button>
        )}
      </div>
      {items.length === 0 ? (
        <p className="text-gray-500">Your cart is empty</p>
      ) : (
        <div className="space-y-4">
          {items.map(item => (
            <div key={item.id} className="flex justify-between items-center p-2 border rounded">
              <div>
                <h3 className="font-semibold">{item.name}</h3>
                <p className="text-gray-600">${item.price}</p>
              </div>
              <div className="flex items-center space-x-2">
                <input
                  type="number"
                  min="1"
                  value={item.quantity}
                  onChange={e => updateQuantity(item.id, parseInt(e.target.value))}
                  className="w-16 p-1 border rounded"
                />
                <button
                  onClick={() => removeItem(item.id)}
                  className="text-red-500 hover:text-red-600"
                >
                  Remove
                </button>
              </div>
            </div>
          ))}
          <div className="pt-4 border-t">
            <p className="text-xl font-bold">Total: ${total.toFixed(2)}</p>
          </div>
        </div>
      )}
    </div>
  );
};

// Main App Component
const App = () => {
  return (
    <CartProvider>
      <div className="p-4 max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold mb-6">Shopping Cart Demo (with LocalStorage)</h1>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          <ProductList />
          <ShoppingCart />
        </div>
      </div>
    </CartProvider>
  );
};

export default App;





Enter fullscreen mode Exit fullscreen mode

Pros of LocalStorage Approach

✅ Persists across refreshes
✅ Simple to implement
✅ No extra dependencies
✅ Works offline

Cons of LocalStorage Approach

❌ Limited storage space (5-10MB)
❌ Synchronous operations can block UI
❌ No structured queries
❌ No built-in indexing
❌ Manual cross-tab sync needed

Approach 3: IndexedDB with Custom Hook

Now let's look at a more robust solution using IndexedDB:

import React, { createContext, useContext, useState, useEffect } from 'react';

// IndexedDB setup
const initDB = () => {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('ShoppingCartDB', 1);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains('cart')) {
        db.createObjectStore('cart', { keyPath: 'id' });
      }
    };
  });
};

// Cart Context
const CartContext = createContext();

const CartProvider = ({ children }) => {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [db, setDb] = useState(null);

  // Initialize IndexedDB
  useEffect(() => {
    initDB()
      .then(database => {
        setDb(database);
        // Load initial data
        const transaction = database.transaction('cart', 'readonly');
        const store = transaction.objectStore('cart');
        const request = store.getAll();

        request.onsuccess = () => {
          setItems(request.result);
          setLoading(false);
        };
      })
      .catch(error => {
        console.error('Failed to initialize DB:', error);
        setLoading(false);
      });
  }, []);

  // Handle cross-tab communication
  useEffect(() => {
    const channel = new BroadcastChannel('shopping-cart');

    const handleMessage = (event) => {
      if (event.data.type === 'CART_UPDATED' && db) {
        const transaction = db.transaction('cart', 'readonly');
        const store = transaction.objectStore('cart');
        const request = store.getAll();

        request.onsuccess = () => {
          setItems(request.result);
        };
      }
    };

    channel.addEventListener('message', handleMessage);
    return () => {
      channel.removeEventListener('message', handleMessage);
      channel.close();
    };
  }, [db]);

  const notifyOtherTabs = () => {
    const channel = new BroadcastChannel('shopping-cart');
    channel.postMessage({ type: 'CART_UPDATED' });
    channel.close();
  };

  const addItem = async (product) => {
    if (!db) return;

    const existingItem = items.find(item => item.id === product.id);
    const newItem = existingItem
      ? { ...existingItem, quantity: existingItem.quantity + 1 }
      : { ...product, quantity: 1 };

    // Optimistic update
    setItems(prev => {
      if (existingItem) {
        return prev.map(item =>
          item.id === product.id ? newItem : item
        );
      }
      return [...prev, newItem];
    });

    try {
      const transaction = db.transaction('cart', 'readwrite');
      const store = transaction.objectStore('cart');
      await new Promise((resolve, reject) => {
        const request = store.put(newItem);
        request.onsuccess = () => resolve();
        request.onerror = () => reject(request.error);
      });
      notifyOtherTabs();
    } catch (error) {
      console.error('Failed to add item:', error);
      setItems(prev => existingItem ? prev : prev.filter(item => item.id !== product.id));
    }
  };

  const updateQuantity = async (productId, quantity) => {
    if (!db || quantity < 1) return;

    const item = items.find(item => item.id === productId);
    if (!item) return;

    const updatedItem = { ...item, quantity };

    // Optimistic update
    setItems(prev =>
      prev.map(item => item.id === productId ? updatedItem : item)
    );

    try {
      const transaction = db.transaction('cart', 'readwrite');
      const store = transaction.objectStore('cart');
      await new Promise((resolve, reject) => {
        const request = store.put(updatedItem);
        request.onsuccess = () => resolve();
        request.onerror = () => reject(request.error);
      });
      notifyOtherTabs();
    } catch (error) {
      console.error('Failed to update quantity:', error);
      setItems(prev =>
        prev.map(item => item.id === productId ? { ...item, quantity: item.quantity } : item)
      );
    }
  };

  const removeItem = async (productId) => {
    if (!db) return;

    const removedItem = items.find(item => item.id === productId);

    // Optimistic update
    setItems(prev => prev.filter(item => item.id !== productId));

    try {
      const transaction = db.transaction('cart', 'readwrite');
      const store = transaction.objectStore('cart');
      await new Promise((resolve, reject) => {
        const request = store.delete(productId);
        request.onsuccess = () => resolve();
        request.onerror = () => reject(request.error);
      });
      notifyOtherTabs();
    } catch (error) {
      console.error('Failed to remove item:', error);
      if (removedItem) {
        setItems(prev => [...prev, removedItem]);
      }
    }
  };

  const clearCart = async () => {
    if (!db) return;

    const oldItems = [...items];

    // Optimistic update
    setItems([]);

    try {
      const transaction = db.transaction('cart', 'readwrite');
      const store = transaction.objectStore('cart');
      await new Promise((resolve, reject) => {
        const request = store.clear();
        request.onsuccess = () => resolve();
        request.onerror = () => reject(request.error);
      });
      notifyOtherTabs();
    } catch (error) {
      console.error('Failed to clear cart:', error);
      setItems(oldItems);
    }
  };

  return (
    <CartContext.Provider value={{ 
      items, 
      loading,
      addItem, 
      updateQuantity, 
      removeItem,
      clearCart,
      total: items.reduce((sum, item) => sum + (item.price * item.quantity), 0)
    }}>
      {children}
    </CartContext.Provider>
  );
};

// Sample products data
const SAMPLE_PRODUCTS = [
  { id: 1, name: 'Laptop', price: 999 },
  { id: 2, name: 'Smartphone', price: 699 },
  { id: 3, name: 'Headphones', price: 199 },
];

// Product List Component
const ProductList = () => {
  const { addItem } = useContext(CartContext);

  return (
    <div className="border p-4 rounded-lg">
      <h2 className="text-xl font-bold mb-4">Products</h2>
      <div className="space-y-4">
        {SAMPLE_PRODUCTS.map(product => (
          <div key={product.id} className="flex justify-between items-center p-2 border rounded">
            <div>
              <h3 className="font-semibold">{product.name}</h3>
              <p className="text-gray-600">${product.price}</p>
            </div>
            <button
              onClick={() => addItem(product)}
              className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
            >
              Add to Cart
            </button>
          </div>
        ))}
      </div>
    </div>
  );
};

// Shopping Cart Component
const ShoppingCart = () => {
  const { items, loading, updateQuantity, removeItem, clearCart, total } = useContext(CartContext);

  if (loading) {
    return (
      <div className="border p-4 rounded-lg">
        <h2 className="text-xl font-bold mb-4">Shopping Cart</h2>
        <p className="text-gray-500">Loading cart...</p>
      </div>
    );
  }

  return (
    <div className="border p-4 rounded-lg">
      <div className="flex justify-between items-center mb-4">
        <h2 className="text-xl font-bold">Shopping Cart</h2>
        {items.length > 0 && (
          <button
            onClick={clearCart}
            className="text-red-500 hover:text-red-600"
          >
            Clear Cart
          </button>
        )}
      </div>
      {items.length === 0 ? (
        <p className="text-gray-500">Your cart is empty</p>
      ) : (
        <div className="space-y-4">
          {items.map(item => (
            <div key={item.id} className="flex justify-between items-center p-2 border rounded">
              <div>
                <h3 className="font-semibold">{item.name}</h3>
                <p className="text-gray-600">${item.price}</p>
              </div>
              <div className="flex items-center space-x-2">
                <input
                  type="number"
                  min="1"
                  value={item.quantity}
                  onChange={e => updateQuantity(item.id, parseInt(e.target.value))}
                  className="w-16 p-1 border rounded"
                />
                <button
                  onClick={() => removeItem(item.id)}
                  className="text-red-500 hover:text-red-600"
                >
                  Remove
                </button>
              </div>
            </div>
          ))}
          <div className="pt-4 border-t">
            <p className="text-xl font-bold">Total: ${total.toFixed(2)}</p>
          </div>
        </div>
      )}
    </div>
  );
};

// Main App Component
const App = () => {
  return (
    <CartProvider>
      <div className="p-4 max-w-6xl mx-auto">
        <h1 className="text-3xl font-bold mb-6">Shopping Cart Demo (with IndexedDB)</h1>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
          <ProductList />
          <ShoppingCart />
        </div>
      </div>
    </CartProvider>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Pros of Hybrid Approach

✅ Persists across refreshes and browser restarts
✅ Works offline
✅ Handles large datasets efficiently
✅ Cross-tab synchronization
✅ Optimistic updates for better UX
✅ Structured queries and indexing
✅ Error handling with rollbacks

Cons of Hybrid Approach

❌ More complex implementation
❌ Requires understanding of IndexedDB
❌ Slightly more boilerplate code

Beyond Implementation: An Architectural Perspective

Before diving into specific implementations, it's crucial to understand that browser-based state management presents a finite set of fundamental challenges and corresponding solutions.

The browser environment offers us distinct capabilities (Context, localStorage, IndexedDB) and constraints (storage limits, tab isolation, offline scenarios), creating a well-defined design space.

Each solution we explore - from simple Context to sophisticated IndexedDB implementations - isn't just a coding pattern, but a specific response to particular architectural needs. By understanding these recurring properties (like state persistence, real-time synchronization, and performance) and their relationships (such as the tension between immediacy and consistency), we can make informed decisions about which implementation best suits our needs.

Rather than viewing these solutions as a spectrum from simple to complex, we can see them as different compositions of fundamental patterns addressing specific combinations of requirements.

This architectural perspective helps us not only choose the right approach but also understand how our solution might need to evolve as requirements change.

Conclusion

After building shopping carts using different approaches, here's when to use each:

  • Use Context Only: For small apps with no persistence needs
  • Use LocalStorage + Context: For medium-sized apps with simple storage needs
  • Use IndexedDB + Custom Hook: For production apps that need reliability and performance
  • Use Hybrid Approach: For the best balance of features and complexity

The complete code for all approaches is available in this GitHub repository: Shopping Cart Options

Synchronization with Remote Database

One powerful advantage of using IndexedDB is the ability to implement offline-first functionality with background synchronization ,
-shall cover in a follow up article,

Join the Discussion!

  1. What's your go-to approach for handling offline data in web apps? Have you tried different methods, and what made you choose one over others?

  2. When building apps that need to stay in sync (across tabs, devices, or users), what unexpected challenges did you run into? Any interesting solutions you'd like to share?

  3. Do you find yourself seeing similar patterns in different architectural challenges? How do you decide between various technical approaches when starting a new project?

Share your experiences below!


Top comments (0)