Cart Page and Cart Drawer Intermediate 12 min read

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:

  1. Has increment/decrement buttons
  2. Updates via AJAX without page reload
  3. Shows a loading state during updates
  4. Handles errors gracefully
  5. Updates line totals and cart total
  6. Works without JavaScript (form fallback)

Test scenarios:

  • Normal quantity changes
  • Setting quantity to 0 (remove)
  • Exceeding available stock
  • Network errors

Key Takeaways

  1. Use /cart/change.js for individual item updates
  2. Use /cart/update.js for bulk updates
  3. Debounce input changes to prevent rapid requests
  4. Show loading states during API calls
  5. Handle errors and show user feedback
  6. Optimistic updates improve perceived performance
  7. Use Section Rendering for full refresh
  8. 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...