Cart Components Intermediate 10 min read

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 components

See Cart Drawer Architecture for drawer setup and this lesson for sharing logic between both contexts.

Data Source

Prop/StateSourceLiquid Field
cartZustand store (hydrated from JSON)cart object in theme.liquid
cart.itemsStore / API refreshcart.items
cart.noteStore / API refreshcart.note
cart.totalPriceStore / API refreshcart.total_price
isLoadingLocal state-
variantCartProvider 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

src/hooks/useCartContext.ts
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

src/components/cart/CartLineItem/CartLineItem.tsx
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>
);
}
src/components/cart/CartLineItem/CartLineItem.module.css
/* 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

src/components/cart/CartPage/CartPage.tsx
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>
);
}
src/components/cart/CartPage/CartPage.module.css
.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

src/components/cart/CartPage/CartNotes.tsx
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>
);
}
src/components/cart/CartPage/CartNotes.module.css
.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

  1. Shared state: Use a single Zustand store for both drawer and page
  2. Context provider: Wrap components to provide variant-specific behavior
  3. Variant prop: Let components adapt layout based on context
  4. CSS variants: Use modifier classes (.drawer, .page) for different layouts
  5. Reusable components: Line items, totals, and controls work in both contexts
  6. 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...