Cart Components Intermediate 12 min read
Line Item Component with Quantity Updates
Build cart line item components with quantity controls, remove buttons, and optimistic updates. Handle variant info and line item properties.
Cart line items are the heart of the cart experience. Each item needs to display product info, handle quantity changes, support removal, and provide visual feedback during updates. Let’s build it right.
Theme Integration
This component is part of the cart drawer component hierarchy:
snippets/cart-drawer.liquid (included in theme.liquid)└── <div id="cart-drawer-root"> └── CartDrawer (React) └── CartItems ← You are here └── CartLineItem (multiple)See Cart Drawer Architecture for the complete Liquid snippet setup and cart data serialization.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
items | Zustand store | cart.items |
item.key | Store | item.key (unique line identifier) |
item.title | Store | item.product.title |
item.variantTitle | Store | item.variant.title |
item.quantity | Store | item.quantity |
item.price | Store | item.variant.price |
item.linePrice | Store | item.line_price |
item.discountedPrice | Store | item.final_line_price |
item.image | Store | item.image |
item.url | Store | item.url |
item.properties | Store | item.properties |
isPending | Zustand store | - (tracks pending API calls) |
CartItems Container
import type { CartLineItem } from '@/types/cart';import { CartLineItem as LineItem } from './CartLineItem';import styles from './CartItems.module.css';
interface CartItemsProps { items: CartLineItem[]; // Array of line items from cart state.}
/** * CartItems renders the list of line items in the cart. * Uses semantic list markup for accessibility. */export function CartItems({ items }: CartItemsProps) { return ( <ul className={styles.list} role="list"> {items.map((item) => ( <li key={item.key} className={styles.item}> <LineItem item={item} /> </li> ))} </ul> );}.list { list-style: none; margin: 0; padding: 0;}
.item { /* Border between items, not on last */ border-bottom: 1px solid var(--color-border);}
.item:last-child { border-bottom: none;}CartLineItem Component
import { useState } from 'react';import type { CartLineItem as LineItemType } from '@/types/cart';import { useCart } from '@/stores/cart';import { formatMoney } from '@/utils/money';import { QuantitySelector } from './QuantitySelector';import { RemoveButton } from './RemoveButton';import styles from './CartLineItem.module.css';
interface CartLineItemProps { item: LineItemType; // Single line item data.}
/** * CartLineItem displays a single cart item with image, details, * quantity controls, and remove button. */export function CartLineItem({ item }: CartLineItemProps) { // Get cart actions from store. const { updateItem, removeItem, isItemPending } = useCart();
// Check if this item is currently being updated. const isPending = isItemPending(item.key);
// Handle quantity change - calls store action. const handleQuantityChange = async (newQuantity: number) => { if (newQuantity < 1) return; await updateItem(item.key, newQuantity); };
// Handle remove - calls store action. const handleRemove = async () => { await removeItem(item.key); };
return ( <article className={`${styles.lineItem} ${isPending ? styles.pending : ''}`} aria-busy={isPending} > {/* Product image with link */} <a href={item.url} className={styles.imageLink}> {item.image ? ( <img src={item.image} alt={item.title} className={styles.image} width={80} height={80} loading="lazy" /> ) : ( <div className={styles.imagePlaceholder}> <ImagePlaceholderIcon /> </div> )} </a>
{/* Product info */} <div className={styles.info}> {/* Title links to product page */} <a href={item.url} className={styles.title}> {item.title} </a>
{/* Variant title (e.g., "Red / Large") */} {item.variantTitle && item.variantTitle !== 'Default Title' && ( <p className={styles.variant}>{item.variantTitle}</p> )}
{/* Line item properties (custom fields) */} {item.properties && Object.keys(item.properties).length > 0 && ( <LineItemProperties properties={item.properties} /> )}
{/* Price display */} <div className={styles.pricing}> {/* Show discounted price if different from original */} {item.discountedPrice < item.price ? ( <> <span className={styles.originalPrice}> {formatMoney(item.price)} </span> <span className={styles.salePrice}> {formatMoney(item.discountedPrice)} </span> </> ) : ( <span className={styles.price}>{formatMoney(item.price)}</span> )} </div> </div>
{/* Quantity and remove controls */} <div className={styles.controls}> <QuantitySelector quantity={item.quantity} onChange={handleQuantityChange} disabled={isPending} max={item.available ? undefined : item.quantity} // Can't increase if unavailable. />
<RemoveButton onClick={handleRemove} disabled={isPending} /> </div>
{/* Line total */} <div className={styles.lineTotal}> <span className={styles.lineTotalLabel}>Total</span> <span className={styles.lineTotalValue}> {formatMoney(item.linePrice)} </span> </div>
{/* Pending overlay */} {isPending && <div className={styles.pendingOverlay} />} </article> );}
/** * Renders custom line item properties (gift notes, customizations, etc.). */function LineItemProperties({ properties }: { properties: Record<string, string> }) { // Filter out properties starting with underscore (hidden properties). const visibleProperties = Object.entries(properties).filter( ([key]) => !key.startsWith('_') );
if (visibleProperties.length === 0) return null;
return ( <dl className={styles.properties}> {visibleProperties.map(([key, value]) => ( <div key={key} className={styles.property}> <dt className={styles.propertyKey}>{key}:</dt> <dd className={styles.propertyValue}>{value}</dd> </div> ))} </dl> );}
/** * Placeholder icon for items without images. */function ImagePlaceholderIcon() { return ( <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <rect x="3" y="3" width="18" height="18" rx="2" /> <circle cx="8.5" cy="8.5" r="1.5" /> <polyline points="21 15 16 10 5 21" /> </svg> );}CartLineItem Styles
.lineItem { display: grid; grid-template-columns: 80px 1fr auto; grid-template-rows: auto auto; gap: 0.75rem 1rem; padding: 1rem 0; position: relative; transition: opacity 0.2s ease;}
/* Fade when update is pending. */.lineItem.pending { opacity: 0.6; pointer-events: none;}
/* Pending overlay prevents interaction. */.pendingOverlay { position: absolute; inset: 0; z-index: 1;}
/* Image column */.imageLink { grid-row: 1 / 3; align-self: start;}
.image { width: 80px; height: 80px; object-fit: cover; border-radius: var(--radius-sm); background-color: var(--color-surface);}
.imagePlaceholder { width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; background-color: var(--color-surface); border-radius: var(--radius-sm); color: var(--color-text-muted);}
/* Info column */.info { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; /* Allow text truncation. */}
.title { font-weight: 500; color: var(--color-text); text-decoration: none; /* Truncate long titles. */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.title:hover { text-decoration: underline;}
.variant { margin: 0; font-size: 0.875rem; color: var(--color-text-muted);}
/* Line item properties (custom fields). */.properties { margin: 0.25rem 0 0; font-size: 0.8125rem;}
.property { display: flex; gap: 0.25rem;}
.propertyKey { color: var(--color-text-muted);}
.propertyValue { margin: 0; color: var(--color-text);}
/* Pricing display. */.pricing { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.25rem;}
.price { font-weight: 500;}
.originalPrice { text-decoration: line-through; color: var(--color-text-muted); font-size: 0.875rem;}
.salePrice { color: var(--color-sale); font-weight: 500;}
/* Controls column */.controls { display: flex; flex-direction: column; align-items: flex-end; gap: 0.5rem;}
/* Line total */.lineTotal { grid-column: 2 / -1; display: flex; justify-content: space-between; align-items: center; padding-top: 0.5rem; border-top: 1px dashed var(--color-border);}
.lineTotalLabel { font-size: 0.875rem; color: var(--color-text-muted);}
.lineTotalValue { font-weight: 600;}
/* Mobile: stack layout */@media (max-width: 480px) { .lineItem { grid-template-columns: 70px 1fr; }
.controls { grid-column: 2; flex-direction: row; justify-content: space-between; align-items: center; }
.lineTotal { grid-column: 1 / -1; }}QuantitySelector Component
import { useId } from 'react';import styles from './QuantitySelector.module.css';
interface QuantitySelectorProps { quantity: number; // Current quantity value. onChange: (quantity: number) => void; // Called when quantity changes. disabled?: boolean; // Disable controls during updates. min?: number; // Minimum allowed quantity. max?: number; // Maximum allowed quantity.}
/** * QuantitySelector provides +/- buttons and displays current quantity. * Handles min/max bounds and disabled states. */export function QuantitySelector({ quantity, onChange, disabled = false, min = 1, max = 99,}: QuantitySelectorProps) { const id = useId();
// Check if buttons should be disabled. const canDecrease = quantity > min && !disabled; const canIncrease = quantity < max && !disabled;
return ( <div className={styles.selector}> <label htmlFor={id} className={styles.label}> Quantity </label>
<div className={styles.controls}> {/* Decrease button */} <button type="button" className={styles.button} onClick={() => onChange(quantity - 1)} disabled={!canDecrease} aria-label="Decrease quantity" > <MinusIcon /> </button>
{/* Current quantity display */} <input id={id} type="text" className={styles.input} value={quantity} readOnly aria-label="Quantity" />
{/* Increase button */} <button type="button" className={styles.button} onClick={() => onChange(quantity + 1)} disabled={!canIncrease} aria-label="Increase quantity" > <PlusIcon /> </button> </div> </div> );}
function MinusIcon() { return ( <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="3" y1="7" x2="11" y2="7" /> </svg> );}
function PlusIcon() { return ( <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2"> <line x1="7" y1="3" x2="7" y2="11" /> <line x1="3" y1="7" x2="11" y2="7" /> </svg> );}.selector { display: flex; flex-direction: column; gap: 0.25rem;}
/* Visually hidden but accessible label. */.label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;}
.controls { display: flex; align-items: center; border: 1px solid var(--color-border); border-radius: var(--radius-sm); overflow: hidden;}
.button { display: flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; border: none; background-color: var(--color-surface); color: var(--color-text); cursor: pointer; transition: background-color 0.15s ease;}
.button:hover:not(:disabled) { background-color: var(--color-surface-hover);}
.button:disabled { opacity: 0.4; cursor: not-allowed;}
.input { width: 2.5rem; height: 2rem; padding: 0; border: none; border-left: 1px solid var(--color-border); border-right: 1px solid var(--color-border); background-color: transparent; text-align: center; font-size: 0.875rem; font-weight: 500; color: var(--color-text);}
/* Remove number input spinners. */.input::-webkit-inner-spin-button,.input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0;}
.input[type='number'] { -moz-appearance: textfield;}RemoveButton Component
import styles from './RemoveButton.module.css';
interface RemoveButtonProps { onClick: () => void; // Called when remove is clicked. disabled?: boolean; // Disable during pending operations.}
/** * RemoveButton allows removing an item from the cart. * Shows a trash icon with accessible label. */export function RemoveButton({ onClick, disabled = false }: RemoveButtonProps) { return ( <button type="button" className={styles.button} onClick={onClick} disabled={disabled} aria-label="Remove item" > <TrashIcon /> <span className={styles.text}>Remove</span> </button> );}
function TrashIcon() { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"> <path d="M2 4h12M5.333 4V2.667a1.333 1.333 0 0 1 1.334-1.334h2.666a1.333 1.333 0 0 1 1.334 1.334V4m2 0v9.333a1.333 1.333 0 0 1-1.334 1.334H4.667a1.333 1.333 0 0 1-1.334-1.334V4h9.334z" /> </svg> );}.button { display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.375rem 0.5rem; border: none; background: transparent; color: var(--color-text-muted); font-size: 0.75rem; cursor: pointer; border-radius: var(--radius-sm); transition: color 0.15s ease, background-color 0.15s ease;}
.button:hover:not(:disabled) { color: var(--color-error); background-color: rgba(239, 68, 68, 0.1);}
.button:disabled { opacity: 0.4; cursor: not-allowed;}
.text { /* Hide text on mobile, show icon only. */}
@media (max-width: 480px) { .text { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }}Optimistic Updates in Cart Store
// src/stores/cart.ts - Optimistic update pattern for line itemsimport { create } from 'zustand';import type { Cart, CartLineItem } from '@/types/cart';import * as cartApi from '@/api/cart';
interface CartState { cart: Cart | null; pendingUpdates: Set<string>; // Track items being updated. error: string | null;
// Actions updateItem: (key: string, quantity: number) => Promise<void>; removeItem: (key: string) => Promise<void>;
// Helpers isItemPending: (key: string) => boolean;}
export const useCart = create<CartState>((set, get) => ({ cart: null, pendingUpdates: new Set(), error: null,
// Check if a specific item is pending. isItemPending: (key) => get().pendingUpdates.has(key),
// Update item quantity with optimistic UI. updateItem: async (key, quantity) => { const previousCart = get().cart; if (!previousCart) return;
// Mark item as pending. set((state) => ({ pendingUpdates: new Set(state.pendingUpdates).add(key), error: null, }));
// Optimistic update: immediately update the UI. set((state) => ({ cart: state.cart ? optimisticUpdateQuantity(state.cart, key, quantity) : null, }));
try { // Make API call. const serverCart = await cartApi.updateLineItem(key, quantity); // Update with server response (source of truth). set({ cart: transformCart(serverCart) }); } catch (error) { // Rollback on error. set({ cart: previousCart, error: 'Failed to update quantity', }); } finally { // Clear pending state. set((state) => { const pending = new Set(state.pendingUpdates); pending.delete(key); return { pendingUpdates: pending }; }); } },
// Remove item with optimistic UI. removeItem: async (key) => { const previousCart = get().cart; if (!previousCart) return;
// Mark as pending. set((state) => ({ pendingUpdates: new Set(state.pendingUpdates).add(key), error: null, }));
// Optimistic remove: immediately remove from UI. set((state) => ({ cart: state.cart ? optimisticRemoveItem(state.cart, key) : null, }));
try { const serverCart = await cartApi.removeLineItem(key); set({ cart: transformCart(serverCart) }); } catch (error) { // Rollback on error. set({ cart: previousCart, error: 'Failed to remove item', }); } finally { set((state) => { const pending = new Set(state.pendingUpdates); pending.delete(key); return { pendingUpdates: pending }; }); } },}));
// Helper: optimistically update quantity.function optimisticUpdateQuantity(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, }; });
return recalculateCart(cart, items);}
// Helper: optimistically remove item.function optimisticRemoveItem(cart: Cart, key: string): Cart { const items = cart.items.filter((item) => item.key !== key); return recalculateCart(cart, items);}
// Helper: recalculate cart totals.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.linePrice, 0);
return { ...cart, items, itemCount, totalPrice, };}Key Takeaways
- Optimistic updates: Update UI immediately, rollback on error
- Pending states: Track and display loading states per item
- Accessibility: Use proper labels, ARIA attributes, and semantic HTML
- Line properties: Support custom fields like gift notes
- Responsive design: Adapt layout for mobile screens
- Visual feedback: Show discounts, sale prices, and totals clearly
In the next lesson, we’ll build the cart totals and discount display components.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...