Updating Quantity: Server and AJAX Approach
Implement cart quantity updates using both traditional form submission and modern AJAX approaches with loading states and error handling.
Updating cart quantities is a core cart interaction. Let’s explore both server-side form submission and AJAX approaches, with proper error handling and user feedback.
Traditional Form Submission
The simplest approach uses a standard form:
<form action="{{ routes.cart_url }}" method="post"> {%- for item in cart.items -%} <div class="cart-item"> <p>{{ item.product.title }}</p>
<input type="number" name="updates[]" value="{{ item.quantity }}" min="0" aria-label="Quantity for {{ item.product.title }}" > </div> {%- endfor -%}
<button type="submit" name="update">Update Cart</button></form>This approach:
- Works without JavaScript
- Reloads the page on submit
- Updates all quantities at once
Using Line Item Keys
For more precise control, use line item keys:
<form action="{{ routes.cart_url }}/change" method="post"> <input type="hidden" name="id" value="{{ item.key }}"> <input type="number" name="quantity" value="{{ item.quantity }}" min="0"> <button type="submit">Update</button></form>Cart AJAX API
Shopify provides several AJAX endpoints:
/cart.js
Get current cart state:
const cart = await fetch('/cart.js').then(r => r.json());/cart/change.js
Update a specific line item:
await fetch('/cart/change.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'line-item-key', // or variant_id quantity: 2 })});/cart/update.js
Update multiple items:
await fetch('/cart/update.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates: { 'line-item-key-1': 2, 'line-item-key-2': 3 } })});AJAX Quantity Update
class CartQuantity extends HTMLElement { constructor() { super(); this.input = this.querySelector('input'); this.key = this.dataset.key; }
connectedCallback() { this.input.addEventListener('change', this.onQuantityChange.bind(this));
// Increment/decrement buttons this.querySelector('[data-decrease]')?.addEventListener('click', () => { this.updateQuantity(parseInt(this.input.value) - 1); });
this.querySelector('[data-increase]')?.addEventListener('click', () => { this.updateQuantity(parseInt(this.input.value) + 1); }); }
onQuantityChange() { this.updateQuantity(parseInt(this.input.value)); }
async updateQuantity(quantity) { // Ensure quantity is valid quantity = Math.max(0, quantity);
this.showLoading();
try { const response = await fetch('/cart/change.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: this.key, quantity: quantity }) });
if (!response.ok) { throw new Error('Failed to update cart'); }
const cart = await response.json();
// Dispatch event for other components document.dispatchEvent(new CustomEvent('cart:updated', { detail: { cart } }));
// Handle item removed if (quantity === 0) { this.handleItemRemoved(); }
} catch (error) { this.showError(error.message); // Restore previous value this.input.value = this.dataset.originalQuantity; } finally { this.hideLoading(); } }
showLoading() { this.classList.add('is-loading'); this.input.disabled = true; }
hideLoading() { this.classList.remove('is-loading'); this.input.disabled = false; }
showError(message) { // Show error feedback console.error(message); }
handleItemRemoved() { const item = this.closest('.cart-item'); item.classList.add('is-removing');
setTimeout(() => { item.remove();
// Check if cart is empty const remainingItems = document.querySelectorAll('.cart-item'); if (remainingItems.length === 0) { document.dispatchEvent(new CustomEvent('cart:empty')); } }, 300); }}
customElements.define('cart-quantity', CartQuantity);Complete Quantity Component
{# snippets/cart-quantity.liquid #}
<cart-quantity class="cart-quantity" data-key="{{ item.key }}" data-original-quantity="{{ item.quantity }}"> <button type="button" class="cart-quantity__button" data-decrease aria-label="Decrease quantity" {% if item.quantity <= 1 %}disabled{% endif %} > <span aria-hidden="true">−</span> </button>
<input type="number" class="cart-quantity__input" value="{{ item.quantity }}" min="0" max="{{ item.variant.inventory_quantity | default: 99 }}" step="1" name="updates[]" aria-label="Quantity for {{ item.product.title }}" >
<button type="button" class="cart-quantity__button" data-increase aria-label="Increase quantity" > <span aria-hidden="true">+</span> </button>
<div class="cart-quantity__loading" hidden> {% render 'loading-spinner' %} </div></cart-quantity>Debounced Updates
Prevent rapid API calls when users type quickly:
class CartQuantity extends HTMLElement { constructor() { super(); this.debounceTimer = null; }
onQuantityChange() { clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => { this.updateQuantity(parseInt(this.input.value)); }, 500); }}Optimistic Updates
Update UI immediately, then sync with server:
async updateQuantity(quantity) { const originalQuantity = parseInt(this.input.value);
// Optimistically update UI this.input.value = quantity; this.updateLineTotal(quantity);
try { const response = await fetch('/cart/change.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: this.key, quantity }) });
if (!response.ok) throw new Error('Failed');
const cart = await response.json(); this.updateCartTotals(cart);
} catch (error) { // Rollback on failure this.input.value = originalQuantity; this.updateLineTotal(originalQuantity); this.showError('Could not update quantity'); }}
updateLineTotal(quantity) { const price = parseInt(this.dataset.price); const lineTotal = quantity * price;
const totalEl = this.closest('.cart-item').querySelector('[data-line-total]'); if (totalEl) { totalEl.textContent = formatMoney(lineTotal); }}
updateCartTotals(cart) { // Update subtotal document.querySelectorAll('[data-cart-subtotal]').forEach(el => { el.textContent = formatMoney(cart.total_price); });
// Update item count document.querySelectorAll('[data-cart-count]').forEach(el => { el.textContent = cart.item_count; });}Loading States
.cart-quantity { position: relative; display: inline-flex; align-items: center;}
.cart-quantity.is-loading { pointer-events: none;}
.cart-quantity.is-loading .cart-quantity__input,.cart-quantity.is-loading .cart-quantity__button { opacity: 0.5;}
.cart-quantity__loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.8);}
.cart-quantity.is-loading .cart-quantity__loading { display: flex;}Error Handling
async updateQuantity(quantity) { try { const response = await fetch('/cart/change.js', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: this.key, quantity }) });
const data = await response.json();
if (!response.ok) { // Shopify returns error info throw new Error(data.description || data.message || 'Update failed'); }
// Check if quantity was adjusted (e.g., limited stock) const lineItem = data.items.find(item => item.key === this.key); if (lineItem && lineItem.quantity !== quantity) { this.input.value = lineItem.quantity; this.showNotification(`Only ${lineItem.quantity} available`); }
} catch (error) { this.showError(error.message); }}
showError(message) { const errorEl = document.createElement('div'); errorEl.className = 'cart-quantity__error'; errorEl.textContent = message; errorEl.setAttribute('role', 'alert');
this.appendChild(errorEl);
setTimeout(() => errorEl.remove(), 3000);}Refreshing Cart Content
Use Section Rendering for full refresh:
async refreshCart() { const response = await fetch('/?section_id=cart-items'); const html = await response.text();
const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html');
const newContent = doc.querySelector('.cart-items'); const currentContent = document.querySelector('.cart-items');
if (newContent && currentContent) { currentContent.innerHTML = newContent.innerHTML; }
// Also update totals const newTotals = doc.querySelector('.cart-totals'); const currentTotals = document.querySelector('.cart-totals');
if (newTotals && currentTotals) { currentTotals.innerHTML = newTotals.innerHTML; }}Complete Cart Item with Quantity
{# snippets/cart-item.liquid #}
<div class="cart-item" data-cart-item data-key="{{ item.key }}"> <a href="{{ item.url }}" class="cart-item__image"> <img src="{{ item.image | image_url: width: 150 }}" alt="{{ item.image.alt | default: item.product.title }}" width="75" height="75" loading="lazy" > </a>
<div class="cart-item__details"> <a href="{{ item.url }}" class="cart-item__title"> {{ item.product.title }} </a>
{%- if item.variant.title != 'Default Title' -%} <p class="cart-item__variant">{{ item.variant.title }}</p> {%- endif -%}
<p class="cart-item__price">{{ item.price | money }}</p> </div>
<div class="cart-item__quantity"> {%- render 'cart-quantity', item: item -%} </div>
<div class="cart-item__total" data-line-total> {{ item.final_line_price | money }} </div>
<button type="button" class="cart-item__remove" data-remove-item data-key="{{ item.key }}" aria-label="Remove {{ item.product.title }}" > {% render 'icon-trash' %} </button></div>No-JS Fallback
Always provide a working fallback:
<form action="{{ routes.cart_url }}" method="post" class="cart-form"> {%- for item in cart.items -%} {% render 'cart-item', item: item %} {%- endfor -%}
<noscript> <button type="submit" name="update" class="button"> Update Cart </button> </noscript></form>Practice Exercise
Build a cart quantity component that:
- Has increment/decrement buttons
- Updates via AJAX without page reload
- Shows a loading state during updates
- Handles errors gracefully
- Updates line totals and cart total
- Works without JavaScript (form fallback)
Test scenarios:
- Normal quantity changes
- Setting quantity to 0 (remove)
- Exceeding available stock
- Network errors
Key Takeaways
- Use
/cart/change.jsfor individual item updates - Use
/cart/update.jsfor bulk updates - Debounce input changes to prevent rapid requests
- Show loading states during API calls
- Handle errors and show user feedback
- Optimistic updates improve perceived performance
- Use Section Rendering for full refresh
- Provide no-JS fallback with forms
What’s Next?
The next lesson covers Removing Items, Notes, and Discounts for additional cart operations.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...