Cart Page vs Cart Drawer: Shared Logic
Learn how to share cart logic between the full cart page and cart drawer. Build reusable hooks and components that work in both contexts.
A good Shopify theme often has both a cart page (/cart) and a cart drawer. These share 90% of the same logic but have different layouts. Let’s build an architecture that maximizes code reuse.
Theme Integration
The cart page and cart drawer are mounted from different Liquid templates:
Cart Drawer:snippets/cart-drawer.liquid (included in theme.liquid)└── <div id="cart-drawer-root"> └── CartDrawer (React)
Cart Page:templates/cart.liquid → sections/cart-main.liquid└── <div id="cart-page-root"> └── CartPage (React)
Both use the same:└── Zustand cart store└── CartItems, CartLineItem, CartFooter componentsSee Cart Drawer Architecture for drawer setup and this lesson for sharing logic between both contexts.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
cart | Zustand store (hydrated from JSON) | cart object in theme.liquid |
cart.items | Store / API refresh | cart.items |
cart.note | Store / API refresh | cart.note |
cart.totalPrice | Store / API refresh | cart.total_price |
isLoading | Local state | - |
variant | CartProvider prop | - (determined by mount point) |
Data Flow: Cart State (Shared Between Page & Drawer)
1. INITIAL HYDRATION (Server Render) ┌─────────────────────────────────────────────────────────┐ │ theme.liquid │ │ └── <script type="application/json" id="cart-data"> │ │ {{ cart | json }} │ │ </script> │ └─────────────────────────────────────────────────────────┘ ↓2. ZUSTAND STORE INITIALIZATION ┌─────────────────────────────────────────────────────────┐ │ useCart (Zustand store) │ │ └── Reads JSON from #cart-data on mount │ │ └── Transforms snake_case → camelCase │ │ └── Sets initial cart state │ └─────────────────────────────────────────────────────────┘ ↓3. SHARED STATE - ONE STORE, TWO UIs ┌─────────────────────────────────────────────────────────┐ │ Zustand cart store │ │ │ │ │ ┌────────────────┴────────────────┐ │ │ ↓ ↓ │ │ CartProvider CartProvider │ │ variant="drawer" variant="page" │ │ ↓ ↓ │ │ CartDrawer CartPage │ │ (slides from right) (/cart route) │ └─────────────────────────────────────────────────────────┘ ↓4. USER UPDATES CART (Either UI) ┌─────────────────────────────────────────────────────────┐ │ User Action → updateItem(key, quantity) │ │ │ │ 1. Optimistic update: Immediately update Zustand store │ │ 2. API call: POST /cart/change.js │ │ 3. On success: Sync with API response │ │ 4. On error: Rollback to previous state │ └─────────────────────────────────────────────────────────┘ ↓5. BOTH UIs AUTOMATICALLY UPDATE ┌─────────────────────────────────────────────────────────┐ │ Zustand triggers re-render in ALL subscribed components │ │ │ │ CartDrawer sees new state ← same store → CartPage sees │ │ │ │ Header cart count also updates (if subscribed) │ └─────────────────────────────────────────────────────────┘Key Points:
- Single Source of Truth: One Zustand store serves both CartDrawer and CartPage
- Optimistic Updates: UI updates instantly, API syncs in background
- Automatic Sync: Zustand subscription ensures both UIs stay in sync
- Variant Prop: Components adapt layout (compact drawer vs full-page) based on context
The Problem
┌─────────────────────────────────────────────────────────────────────────────┐│ CART DRAWER ││ ┌─────────────────────────────────────────────────────────────────────────┐││ │ - Slides from right │││ │ - Overlay backdrop │││ │ - Compact layout │││ │ - Quick checkout focus │││ └─────────────────────────────────────────────────────────────────────────┘││ ││ CART PAGE ││ ┌─────────────────────────────────────────────────────────────────────────┐││ │ - Full page layout │││ │ - More room for details │││ │ - Cart notes field │││ │ - Update cart button │││ │ - Continue shopping link │││ └─────────────────────────────────────────────────────────────────────────┘││ ││ ┌─────────────────────────────────────────────────────────────────────────┐││ │ SHARED LOGIC │││ │ - Cart state (Zustand store) │││ │ - Line item updates │││ │ - Remove items │││ │ - Discount codes │││ │ - Totals calculation │││ │ - Empty state │││ └─────────────────────────────────────────────────────────────────────────┘│└─────────────────────────────────────────────────────────────────────────────┘Shared Cart Context Pattern
import { createContext, useContext, ReactNode } from 'react';import { useCart } from '@/stores/cart';
/** * CartContext provides cart data and actions to both * CartDrawer and CartPage components. * * This separates the "what" (cart data) from the "where" (drawer vs page). */interface CartContextValue { // Cart data from store cart: ReturnType<typeof useCart>['cart']; isLoading: boolean; error: string | null;
// Cart actions updateItem: (key: string, quantity: number) => Promise<void>; removeItem: (key: string) => Promise<void>; applyDiscount: (code: string) => Promise<void>; removeDiscount: (code: string) => Promise<void>; updateNote: (note: string) => Promise<void>;
// Pending states isItemPending: (key: string) => boolean;
// Context-specific props variant: 'drawer' | 'page'; onClose?: () => void; // Only for drawer}
const CartContext = createContext<CartContextValue | null>(null);
/** * Hook to access cart context in child components. * Throws if used outside of CartProvider. */export function useCartContext() { const context = useContext(CartContext); if (!context) { throw new Error('useCartContext must be used within CartProvider'); } return context;}
interface CartProviderProps { variant: 'drawer' | 'page'; onClose?: () => void; children: ReactNode;}
/** * CartProvider wraps cart drawer or cart page, * providing shared state and actions. */export function CartProvider({ variant, onClose, children }: CartProviderProps) { // Get all cart state and actions from Zustand store. const store = useCart();
const value: CartContextValue = { ...store, variant, onClose, };
return ( <CartContext.Provider value={value}> {children} </CartContext.Provider> );}Shared Line Item Component
import { useCartContext } from '@/hooks/useCartContext';import type { CartLineItem as LineItemType } from '@/types/cart';import { formatMoney } from '@/utils/money';import { QuantitySelector } from './QuantitySelector';import { RemoveButton } from './RemoveButton';import styles from './CartLineItem.module.css';
interface CartLineItemProps { item: LineItemType;}
/** * CartLineItem works in both drawer and page contexts. * Uses CartContext to determine layout variant. */export function CartLineItem({ item }: CartLineItemProps) { const { variant, updateItem, removeItem, isItemPending } = useCartContext();
const isPending = isItemPending(item.key);
// Determine CSS class based on variant. const containerClass = `${styles.lineItem} ${styles[variant]} ${isPending ? styles.pending : ''}`;
const handleQuantityChange = async (quantity: number) => { if (quantity < 1) return; await updateItem(item.key, quantity); };
const handleRemove = async () => { await removeItem(item.key); };
return ( <article className={containerClass} aria-busy={isPending}> {/* Image - larger on page, smaller in drawer */} <a href={item.url} className={styles.imageLink}> <img src={item.image || '/placeholder.png'} alt={item.title} className={styles.image} loading="lazy" /> </a>
<div className={styles.details}> {/* Product title */} <a href={item.url} className={styles.title}> {item.title} </a>
{/* Variant info */} {item.variantTitle && item.variantTitle !== 'Default Title' && ( <p className={styles.variant}>{item.variantTitle}</p> )}
{/* Line properties (if any) */} {item.properties && Object.keys(item.properties).length > 0 && ( <LineItemProperties properties={item.properties} /> )}
{/* Price display - inline on drawer, block on page */} <div className={styles.pricing}> {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>
{/* Controls section */} <div className={styles.controls}> <QuantitySelector quantity={item.quantity} onChange={handleQuantityChange} disabled={isPending} /> <RemoveButton onClick={handleRemove} disabled={isPending} /> </div>
{/* Line total - shown on page variant */} {variant === 'page' && ( <div className={styles.lineTotal}> {formatMoney(item.linePrice)} </div> )}
{isPending && <div className={styles.overlay} />} </article> );}
function LineItemProperties({ properties }: { properties: Record<string, string> }) { const visible = Object.entries(properties).filter(([key]) => !key.startsWith('_')); if (visible.length === 0) return null;
return ( <dl className={styles.properties}> {visible.map(([key, value]) => ( <div key={key}> <dt>{key}:</dt> <dd>{value}</dd> </div> ))} </dl> );}/* Base styles for both variants */.lineItem { display: grid; gap: 0.75rem; padding: 1rem 0; border-bottom: 1px solid var(--color-border); position: relative; transition: opacity 0.2s ease;}
.lineItem.pending { opacity: 0.6; pointer-events: none;}
.overlay { position: absolute; inset: 0; z-index: 1;}
/* === DRAWER VARIANT === */.lineItem.drawer { grid-template-columns: 80px 1fr auto; grid-template-rows: auto;}
.drawer .image { width: 80px; height: 80px; object-fit: cover; border-radius: var(--radius-sm);}
.drawer .controls { display: flex; flex-direction: column; align-items: flex-end; gap: 0.5rem;}
/* === PAGE VARIANT === */.lineItem.page { grid-template-columns: 120px 1fr auto 100px; align-items: start;}
.page .image { width: 120px; height: 120px; object-fit: cover; border-radius: var(--radius-sm);}
.page .controls { display: flex; flex-direction: column; align-items: center; gap: 0.5rem;}
.page .lineTotal { text-align: right; font-weight: 600; font-size: 1rem;}
/* Shared styles */.imageLink { display: block;}
.details { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0;}
.title { font-weight: 500; color: var(--color-text); text-decoration: none; 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);}
.properties { margin: 0.25rem 0 0; font-size: 0.8125rem; color: var(--color-text-muted);}
.pricing { display: flex; gap: 0.5rem; margin-top: 0.25rem;}
.price { font-weight: 500;}
.originalPrice { text-decoration: line-through; color: var(--color-text-muted);}
.salePrice { color: var(--color-sale); font-weight: 500;}
/* Responsive for page variant */@media (max-width: 768px) { .lineItem.page { grid-template-columns: 100px 1fr; grid-template-rows: auto auto; }
.page .controls { grid-column: 2; flex-direction: row; justify-content: space-between; }
.page .lineTotal { grid-column: 1 / -1; text-align: left; padding-top: 0.5rem; border-top: 1px dashed var(--color-border); margin-top: 0.5rem; }}Cart Page Implementation
import { CartProvider, useCartContext } from '@/hooks/useCartContext';import { CartLineItem } from '../CartLineItem';import { CartTotals } from '../CartTotals';import { CartEmpty } from '../CartEmpty';import { CartNotes } from './CartNotes';import { Button } from '@/components/ui';import styles from './CartPage.module.css';
/** * CartPage is the full-page cart view at /cart. * Uses CartProvider with 'page' variant. */export function CartPage() { return ( <CartProvider variant="page"> <CartPageContent /> </CartProvider> );}
function CartPageContent() { const { cart, isLoading } = useCartContext();
if (isLoading) { return <CartPageSkeleton />; }
if (!cart || cart.items.length === 0) { return <CartEmpty />; }
return ( <div className={styles.cartPage}> <h1 className={styles.title}>Your Cart</h1>
<div className={styles.layout}> {/* Main cart content */} <div className={styles.main}> {/* Cart items table header (desktop) */} <div className={styles.tableHeader}> <span>Product</span> <span>Details</span> <span>Quantity</span> <span>Total</span> </div>
{/* Line items */} <div className={styles.items}> {cart.items.map((item) => ( <CartLineItem key={item.key} item={item} /> ))} </div>
{/* Cart notes */} <CartNotes />
{/* Continue shopping link */} <a href="/collections/all" className={styles.continueLink}> ← Continue Shopping </a> </div>
{/* Sidebar with totals */} <aside className={styles.sidebar}> <div className={styles.summary}> <h2 className={styles.summaryTitle}>Order Summary</h2> <CartTotals cart={cart} />
<Button as="a" href="/checkout" variant="primary" size="large" fullWidth className={styles.checkoutButton} > Proceed to Checkout </Button>
{/* Trust badges */} <div className={styles.trust}> <p>✓ Secure checkout</p> <p>✓ Free returns within 30 days</p> </div> </div> </aside> </div> </div> );}
function CartPageSkeleton() { return ( <div className={styles.cartPage}> <div className={styles.skeletonTitle} /> <div className={styles.layout}> <div className={styles.main}> {[1, 2, 3].map((i) => ( <div key={i} className={styles.skeletonItem} /> ))} </div> <aside className={styles.sidebar}> <div className={styles.skeletonSummary} /> </aside> </div> </div> );}.cartPage { max-width: var(--container-width, 1200px); margin: 0 auto; padding: 2rem 1.5rem;}
.title { margin: 0 0 2rem; font-size: 2rem; font-weight: 600;}
.layout { display: grid; grid-template-columns: 1fr 380px; gap: 3rem; align-items: start;}
/* Main cart section */.main { display: flex; flex-direction: column;}
.tableHeader { display: grid; grid-template-columns: 120px 1fr auto 100px; gap: 0.75rem; padding: 1rem 0; border-bottom: 2px solid var(--color-border); font-size: 0.875rem; font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em;}
.items { margin-bottom: 2rem;}
.continueLink { color: var(--color-text-muted); text-decoration: underline;}
.continueLink:hover { color: var(--color-text);}
/* Sidebar summary */.sidebar { position: sticky; top: 2rem;}
.summary { padding: 1.5rem; background-color: var(--color-surface); border-radius: var(--radius-md);}
.summaryTitle { margin: 0 0 1rem; font-size: 1.25rem; font-weight: 600;}
.checkoutButton { margin-top: 1.5rem;}
.trust { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--color-border); font-size: 0.8125rem; color: var(--color-text-muted);}
.trust p { margin: 0.25rem 0;}
/* Mobile layout */@media (max-width: 968px) { .layout { grid-template-columns: 1fr; gap: 2rem; }
.tableHeader { display: none; }
.sidebar { position: static; }}
/* Skeleton loading states */.skeletonTitle { height: 2rem; width: 200px; background-color: var(--color-surface); border-radius: var(--radius-sm); margin-bottom: 2rem;}
.skeletonItem { height: 120px; background-color: var(--color-surface); border-radius: var(--radius-sm); margin-bottom: 1rem;}
.skeletonSummary { height: 300px; background-color: var(--color-surface); border-radius: var(--radius-md);}Cart Notes Component
import { useState, useEffect, useCallback } from 'react';import { useCartContext } from '@/hooks/useCartContext';import { useDebouncedCallback } from '@/hooks/useDebounce';import styles from './CartNotes.module.css';
/** * CartNotes allows customers to add special instructions. * Auto-saves with debounce to avoid excessive API calls. */export function CartNotes() { const { cart, updateNote } = useCartContext(); const [note, setNote] = useState(cart?.note || ''); const [isSaving, setIsSaving] = useState(false);
// Sync local state with cart data. useEffect(() => { setNote(cart?.note || ''); }, [cart?.note]);
// Debounced save function - waits 1s after typing stops. const debouncedSave = useDebouncedCallback( async (value: string) => { setIsSaving(true); try { await updateNote(value); } finally { setIsSaving(false); } }, 1000 );
// Handle input change. const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const value = e.target.value; setNote(value); debouncedSave(value); };
return ( <div className={styles.notes}> <label htmlFor="cart-note" className={styles.label}> Order Notes {isSaving && <span className={styles.saving}>Saving...</span>} </label> <textarea id="cart-note" className={styles.textarea} placeholder="Special instructions for your order..." value={note} onChange={handleChange} rows={3} /> </div> );}.notes { margin: 1.5rem 0; padding: 1rem; background-color: var(--color-surface); border-radius: var(--radius-sm);}
.label { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; font-size: 0.875rem; font-weight: 500;}
.saving { font-size: 0.75rem; color: var(--color-text-muted); font-weight: 400;}
.textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); font-family: inherit; font-size: 0.875rem; resize: vertical; min-height: 80px;}
.textarea:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 3px rgba(var(--color-primary-rgb), 0.1);}Key Takeaways
- Shared state: Use a single Zustand store for both drawer and page
- Context provider: Wrap components to provide variant-specific behavior
- Variant prop: Let components adapt layout based on context
- CSS variants: Use modifier classes (
.drawer,.page) for different layouts - Reusable components: Line items, totals, and controls work in both contexts
- Auto-save: Debounce cart notes to reduce API calls
This architecture makes it easy to maintain consistent cart behavior across your entire theme while allowing each context to have its own optimal layout.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...