An e-commerce cart in JavaScript as a SPA

In this article we’ll implement a small e-commerce entirely on the client side.

The architecture is wrapped in an IIFE (Immediately Invoked Function Expression) with strict mode, to avoid leaking into the global namespace and to enable stricter language checks.

'use strict';
(function(){ /* ... */ })();

The Cart class: state, persistence, and rendering

The heart of the app is the Cart class, responsible for managing state, persisting to localStorage, and updating the UI.

Constructor: initialization and restore from storage

On startup, the cart is rebuilt from localStorage if present; otherwise it starts with an empty structure. At this stage it also resolves and stores references to the crucial DOM nodes (counter, cart list, total) and performs an initial save to normalize the state.

class Cart {
  constructor() {
    this.cart = JSON.parse(localStorage.getItem('js-ecommerce-app-cart')) || { items: [], total: 0.00 };
    this.counterElement = document.getElementById('app-cart-total-counter');
    this.cartElement = document.getElementById('app-cart-contents');
    this.cartTotal = document.getElementById('app-cart-total');
    this.save();
  }
}

This setup guarantees persistence across sessions and reduces setup costs when the user reloads the page.

Adding, removing, and updating items

The add operation checks whether the item is already present by comparing the id; if so it increments the quantity, otherwise it inserts a new element and recalculates the total.

add(item) {
  const found = this.cart.items.find(i => i.id === item.id);
  if (found) return this.update(found.id, parseInt(found.quantity,10) + 1);
  this.cart.items.push(item);
  this.total(); this.save();
}

Removal uses findIndex to isolate the row and a controlled splice; the update coerces the quantity to an integer, avoiding surprises from text input.

remove(id) {
  const idx = this.cart.items.findIndex(i => i.id === id);
  if (idx !== -1) { this.cart.items.splice(idx, 1); this.total(); this.save(); }
}

Total calculation and atomic save

The total is computed by iterating over the collection and explicitly converting price and quantity; this prevents errors due to strings. The save serializes the state, updates the counter, and invokes display() for a consistent re-render.

total() {
  let sum = 0.00;
  for (const it of this.cart.items) sum += parseFloat(it.price) * parseInt(it.quantity,10);
  this.cart.total = sum;
}
save() {
  localStorage.setItem('js-ecommerce-app-cart', JSON.stringify(this.cart));
  this.counterElement.innerText = this.cart.items.length;
  this.display();
}

Cart rendering and PayPal integration

The display() method governs the HTML output of the item list: when the cart is empty it shows a dedicated message and resets the total; otherwise it creates li elements with a remove button, unit price, quantity input, and subtotal. Finally, it updates the grand total and syncs a PayPal “cart-style” form with hidden rows for each item.

display() {
  this.cartElement.innerHTML = '';
  if (this.cart.items.length === 0) { /* empty message and reset */ return; }
  for (const item of this.cart.items) {
    const li = document.createElement('li');
    li.innerHTML = `
      <button data-id="${item.id}" class="app-cart-remove" type="button">×</button>
      <h4>${item.id}</h4>
      <input data-id="${item.id}" class="app-input-qty cart-app-input-qty" type="number" value="${item.quantity}">
    `;
    this.cartElement.appendChild(li);
  }
  this.cartTotal.innerText = `$${this.cart.total.toFixed(2)}`;
  this.setPayPalForm();
}

Preparing the PayPal form dynamically generates quantity_n, item_name_n, amount_n, and so on, adhering to the format expected by the gateway.

setPayPalForm() {
  if (this.cart.items.length === 0) return;
  const lines = document.getElementById('app-paypal-form-lines');
  lines.innerHTML = '';
  let idx = 0;
  for (const it of this.cart.items) {
    idx++;
    const line = document.createElement('div');
    line.innerHTML = `
      <input type="hidden" name="quantity_${idx}" value="${it.quantity}">
      <input type="hidden" name="item_name_${idx}" value="${it.id}">
      <input type="hidden" name="amount_${idx}" value="${parseFloat(it.price).toFixed(2)}">
    `;
    lines.appendChild(line);
  }
}

Helpers and UX: prices, images, preloader

To improve the experience, the listing includes simple yet effective utilities. getRandomPrice generates a random price within the requested range, while getRandomImage selects a random preview from a predefined set by shuffling the array with a random sort.

function getRandomPrice(min, max) { return Math.random() * (max - min) + min; }
function getRandomImage() {
  const images = ['images/1.webp','images/2.webp','images/3.webp','images/4.webp'];
  return images.sort(() => Math.random() - 0.5)[0];
}

The showPreloader/hidePreloader pair adds or removes a loaded class from a dedicated element, allowing a loading indicator to be displayed while fetching products.

Data layer: robust catalog loading

Products are requested via fetch with explicit error handling. On success it returns the JSON; otherwise an empty array—a cautious choice that avoids downstream breakage.

async function getProductsData(url = 'data.json') {
  try {
    const res = await fetch(url);
    if (!res.ok) throw new Error('Fetch failed');
    return await res.json();
  } catch (err) {
    console.error(err);
    return [];
  }
}

Event delegation: a single listener for many actions

Main interactions leverage event delegation on the document, reducing the number of listeners and handling dynamically created elements. Each handler instantiates an “ephemeral” Cart that operates on the persistent state, then leaves save() to handle render and synchronization.

Add to cart

The “Add to cart” button reads a JSON-serialized data-item payload from the nearby form, along with the chosen quantity, and passes everything to cart.add.

document.addEventListener('click', (evt) => {
  if (evt.target.classList.contains('app-add-to-cart')) {
    const btn = evt.target;
    const item = JSON.parse(btn.parentNode.dataset.item);
    const qty = parseInt(btn.previousElementSibling.value, 10);
    new Cart().add({ id: item.id, price: parseFloat(item.price), quantity: qty });
  }
});

Removal and quantity update

Removal is driven by a data-id attribute on the delete button, while updating intercepts the change of the numeric input and aligns the quantity in the cart.

document.addEventListener('click', (evt) => {
  if (evt.target.classList.contains('app-cart-remove')) {
    new Cart().remove(evt.target.dataset.id);
  }
});
document.addEventListener('change', (evt) => {
  if (evt.target.classList.contains('cart-app-input-qty')) {
    new Cart().update(evt.target.dataset.id, parseInt(evt.target.value,10));
  }
});

Show and hide the cart

Two functions target dedicated buttons: one applies the visible class to the cart container to open it, the other removes it to close it. Using classes keeps the presentation logic in CSS.

document.getElementById('app-show-cart')
  .addEventListener('click', () => document.getElementById('app-cart').classList.add('visible'));
document.getElementById('close-app-cart')
  .addEventListener('click', () => document.getElementById('app-cart').classList.remove('visible'));

Catalog rendering: dynamic generation of product cards

displayProducts() coordinates preloader, fetch, and DOM. For each catalog item it creates a node with title, brand, random image, generated price, and a minimal form with quantity and purchase button. A data-item attribute carries the essential data (id and price) for the add handler.

async function displayProducts() {
  const preloader = document.getElementById('preloader');
  const container = document.getElementById('app-products');
  showPreloader(preloader);
  try {
    const products = await getProductsData();
    for (const item of products.items) {
      const el = document.createElement('article');
      const price = getRandomPrice(100, 300);
      const productItem = { id: item.model, price: price.toFixed(2) };
      el.className = 'app-product';
      el.innerHTML = `... form with data-item='${JSON.stringify(productItem)}' ...`;
      container.appendChild(el);
    }
  } finally { hidePreloader(preloader); }
}

App bootstrap: a tidy init and a DOMContentLoaded listener

A single init() function registers all handlers and instantiates the initial cart, then its execution is deferred to the DOMContentLoaded event, ensuring the DOM is ready.

function init() {
  new Cart();
  handleAddToCartButton();
  handleRemoveFromCartButton();
  handleUpdateCartQtyButton();
  handleShowCartButton();
  handleHideCartButton();
  displayProducts();
}
document.addEventListener('DOMContentLoaded', init);

Design choices and possible improvements

  • Local persistence: using localStorage offers fast, offline-friendly UX; for multi-device scenarios, a remote sync could be integrated.
  • Event delegation: scales well with dynamic content and reduces listener overhead; be mindful of stopping propagation when needed.
  • Input validation: the code enforces types with parseInt/parseFloat; it may be worth adding limits (min/max) and handling of cases <= 0.
  • Accessibility: labels for quantity inputs, focus management when opening/closing the cart, and ARIA announcements for total updates would improve A11y.
  • PayPal form: field generation is correct; a consistency check between cart and form before submit could be added.
  • Price formatting: using toFixed(2) works; Intl.NumberFormat would make currencies and separators locale-aware.
  • Error handling: the fetch fallback to [] prevents crashes; you could show a user message and a retry button.

Demo

JavaScript E-Commerce App

Conclusion

This example shows how to build a performant e-commerce cart with plain JavaScript: centralized state in a class, local persistence, idempotent rendering, and interactions based on event delegation. It’s a solid foundation, easily extendable towards inventory checks, coupons, wishlists, and additional payment methods, while keeping the code clear and modular.

Back to top