Address Book Management
Build a complete address book with React supporting add, edit, delete, and set default operations. Learn CRUD patterns with Shopify customer address API.
A well-managed address book makes checkout faster and reduces cart abandonment. Returning customers expect to select a saved address rather than retyping everything. Let’s build a complete address management system with add, edit, delete, and set-default functionality.
Why Address Management Matters
Every field a customer has to fill out at checkout is a potential drop-off point. Saved addresses solve this by:
- Reducing friction: One click vs. 10+ form fields
- Minimizing errors: No typos in saved addresses
- Supporting multiple locations: Ship to home, work, or gift recipients
- Enabling fast mobile checkout: Typing addresses on mobile is painful
The address book is where customers manage these options between purchases.
Theme Integration
The address book integrates with Shopify’s customer addresses template:
templates/customers/addresses.liquid└── sections/customer-addresses.liquid └── <div id="address-book-root"> └── AddressBook (React) ├── AddressGrid │ └── AddressCard (multiple) └── AddressFormModal └── AddressFormCRUD Operations with Shopify
Shopify doesn’t provide a JSON API for customer addresses in themes. Instead, we use traditional form submissions to these endpoints:
| Operation | Method | Endpoint |
|---|---|---|
| Create | POST | /account/addresses |
| Update | POST + _method=put | /account/addresses/{id} |
| Delete | POST + _method=delete | /account/addresses/{id} |
| Set Default | POST + _method=put + address[default]=1 | /account/addresses/{id} |
The _method parameter is a common pattern when HTML forms only support GET and POST. Shopify interprets _method=put as an update request and _method=delete as a deletion.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
addresses | JSON script element | customer.addresses |
defaultAddressId | JSON script element | customer.default_address.id |
countries | JSON script element | all_countries |
formAction | Constant | /account/addresses |
The all_countries object from Shopify includes provinces for each country, which we need for dynamic dropdowns.
Address Book Architecture
The layout uses a responsive grid for address cards, with a modal for the add/edit form:
┌──────────────────────────────────────────────────────────────────────────────┐│ ADDRESS BOOK PAGE ││ ││ ┌─────────────────────────────────────────────────────────────────────────┐ ││ │ Your Addresses (3) [+ Add New Address] │ ││ └─────────────────────────────────────────────────────────────────────────┘ ││ ││ ┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────┐ ││ │ AddressCard │ │ AddressCard │ │ AddressCard │ ││ │ ★ DEFAULT │ │ │ │ │ ││ │ │ │ │ │ │ ││ │ John Doe │ │ Jane Doe │ │ Work Address │ ││ │ 123 Main Street │ │ 456 Oak Avenue │ │ 789 Business │ ││ │ New York, NY 10001 │ │ Los Angeles, CA 90001 │ │ Blvd │ ││ │ United States │ │ United States │ │ │ ││ │ │ │ │ │ │ ││ │ ────────────────── │ │ ────────────────── │ │ ────────────── │ ││ │ [Edit] [Delete] │ │ [Edit] [Delete] │ │ [Edit] [Delete]│ ││ │ │ │ [Set as Default] │ │ [Set Default] │ ││ └────────────────────────┘ └────────────────────────┘ └────────────────┘ │└──────────────────────────────────────────────────────────────────────────────┘The default address is visually distinguished and doesn’t show the “Set as Default” button—it already is the default.
TypeScript Interfaces
We define separate types for stored addresses and form data. The form doesn’t need the id field since that comes from the URL during updates:
interface Address { id: number; firstName: string; lastName: string; company: string; address1: string; address2: string; city: string; province: string; provinceCode: string; country: string; countryCode: string; zip: string; phone: string; isDefault: boolean;}
interface AddressFormData { firstName: string; lastName: string; company: string; address1: string; address2: string; city: string; country: string; province: string; zip: string; phone: string; isDefault: boolean;}
/** * Country with its provinces/states. * Used for dynamic dropdown population. */interface Country { name: string; code: string; provinces: Province[];}
interface Province { name: string; code: string;}
interface AddressBookData { addresses: Address[]; defaultAddressId: number | null; countries: Country[]; accountUrl: string;}State Management Approach
The AddressBook component manages several pieces of state:
- addresses: The list of customer addresses
- defaultAddressId: Which address is the default
- isModalOpen: Whether the form modal is visible
- editingAddress: The address being edited (null for new)
- deleteConfirmId: Which address is pending deletion confirmation
import { useState, useCallback } from 'react';import { AddressCard } from './AddressCard';import { AddressFormModal } from './AddressFormModal';import { DeleteConfirmModal } from './DeleteConfirmModal';import { useAddressActions } from './useAddressActions';import styles from './AddressBook.module.css';
interface AddressBookProps { data: AddressBookData;}
export function AddressBook({ data }: AddressBookProps) { // Address list state const [addresses, setAddresses] = useState<Address[]>(data.addresses); const [defaultAddressId, setDefaultAddressId] = useState<number | null>( data.defaultAddressId );
// Modal visibility state const [isModalOpen, setIsModalOpen] = useState(false); const [editingAddress, setEditingAddress] = useState<Address | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
// Custom hook for CRUD operations const { createAddress, updateAddress, deleteAddress, setDefaultAddress } = useAddressActions();
// Open modal for new address (no editing address) const handleAddNew = useCallback(() => { setEditingAddress(null); setIsModalOpen(true); }, []);
// Open modal with existing address data const handleEdit = useCallback((address: Address) => { setEditingAddress(address); setIsModalOpen(true); }, []);
const handleCloseModal = useCallback(() => { setIsModalOpen(false); setEditingAddress(null); }, []);
// Save handles both create and update const handleSave = useCallback( async (formData: AddressFormData) => { if (editingAddress) { await updateAddress(editingAddress.id, formData); } else { await createAddress(formData); } // Page reloads after Shopify processes the form }, [editingAddress, createAddress, updateAddress] );
const handleDelete = useCallback( async (id: number) => { await deleteAddress(id); // Page reloads after deletion }, [deleteAddress] );
const handleSetDefault = useCallback( async (id: number) => { await setDefaultAddress(id); // Page reloads to reflect new default }, [setDefaultAddress] );
return ( <div className={styles.container}> <header className={styles.header}> <div className={styles.headerLeft}> <a href={data.accountUrl} className={styles.backLink}> ← Back to Account </a> <h1 className={styles.title}> Your Addresses {addresses.length > 0 && ( <span className={styles.count}>({addresses.length})</span> )} </h1> </div> <button type="button" className={styles.addButton} onClick={handleAddNew}> + Add New Address </button> </header>
{addresses.length > 0 ? ( <div className={styles.grid}> {addresses.map((address) => ( <AddressCard key={address.id} address={address} isDefault={address.id === defaultAddressId} onEdit={() => handleEdit(address)} onDelete={() => setDeleteConfirmId(address.id)} onSetDefault={() => handleSetDefault(address.id)} /> ))} </div> ) : ( <EmptyState onAddNew={handleAddNew} /> )}
<AddressFormModal isOpen={isModalOpen} address={editingAddress} countries={data.countries} onSave={handleSave} onClose={handleCloseModal} />
<DeleteConfirmModal isOpen={deleteConfirmId !== null} onConfirm={() => deleteConfirmId && handleDelete(deleteConfirmId)} onCancel={() => setDeleteConfirmId(null)} /> </div> );}The useCallback hooks prevent unnecessary re-renders when passing these handlers to child components.
Address Card Design
Each card shows the complete address with action buttons at the bottom. The default address gets special styling:
import styles from './AddressCard.module.css';
interface AddressCardProps { address: Address; isDefault: boolean; onEdit: () => void; onDelete: () => void; onSetDefault: () => void;}
export function AddressCard({ address, isDefault, onEdit, onDelete, onSetDefault,}: AddressCardProps) { return ( <div className={`${styles.card} ${isDefault ? styles.default : ''}`}> {/* Default badge in top-right corner */} {isDefault && ( <div className={styles.badge}> ★ Default </div> )}
{/* Address content using semantic <address> element */} <address className={styles.address}> <strong className={styles.name}> {address.firstName} {address.lastName} </strong> {address.company && ( <span className={styles.company}>{address.company}</span> )} <span>{address.address1}</span> {address.address2 && <span>{address.address2}</span>} <span> {address.city}, {address.provinceCode || address.province} {address.zip} </span> <span>{address.country}</span> {address.phone && ( <span className={styles.phone}>{address.phone}</span> )} </address>
{/* Actions: always Edit/Delete, conditionally Set as Default */} <div className={styles.actions}> <button type="button" onClick={onEdit}>Edit</button> <button type="button" onClick={onDelete} className={styles.deleteButton}> Delete </button> {!isDefault && ( <button type="button" onClick={onSetDefault}> Set as Default </button> )} </div> </div> );}The provinceCode || province fallback handles countries where provinces aren’t standardized—some have codes, some have full names.
Modal Pattern for Forms
Using a modal for address forms keeps the user in context. They can see their existing addresses behind the modal, which provides reassurance that they’re in the right place.
The modal handles:
- Escape key to close
- Click outside to close
- Body scroll lock while open
- Focus trapping for accessibility
import { useEffect, useRef } from 'react';import { AddressForm } from './AddressForm';import styles from './AddressFormModal.module.css';
interface AddressFormModalProps { isOpen: boolean; address: Address | null; // null = creating new countries: Country[]; onSave: (data: AddressFormData) => Promise<void>; onClose: () => void;}
export function AddressFormModal({ isOpen, address, countries, onSave, onClose,}: AddressFormModalProps) { const modalRef = useRef<HTMLDivElement>(null);
// Close on Escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && isOpen) onClose(); }; document.addEventListener('keydown', handleEscape); return () => document.removeEventListener('keydown', handleEscape); }, [isOpen, onClose]);
// Prevent body scroll while modal is open useEffect(() => { if (isOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = ''; } return () => { document.body.style.overflow = ''; }; }, [isOpen]);
// Focus first input when modal opens useEffect(() => { if (isOpen && modalRef.current) { const firstInput = modalRef.current.querySelector('input'); firstInput?.focus(); } }, [isOpen]);
if (!isOpen) return null;
return ( <div className={styles.overlay} onClick={onClose}> <div ref={modalRef} className={styles.modal} onClick={(e) => e.stopPropagation()} // Prevent close when clicking inside role="dialog" aria-modal="true" aria-labelledby="address-form-title" > <div className={styles.header}> <h2 id="address-form-title"> {address ? 'Edit Address' : 'Add New Address'} </h2> <button type="button" onClick={onClose} aria-label="Close">×</button> </div>
<div className={styles.content}> <AddressForm address={address} countries={countries} onSave={onSave} onCancel={onClose} /> </div> </div> </div> );}The stopPropagation() on the modal content is important—without it, clicking inside the form would trigger the overlay’s onClick and close the modal.
Dynamic Country/Province Selects
When the customer selects a country, we need to update the province dropdown. Countries like the US and Canada have provinces; others might not:
// Inside AddressForm componentconst [country, setCountry] = useState(address?.countryCode || 'US');const [province, setProvince] = useState(address?.provinceCode || '');
// Find the selected country to get its provincesconst selectedCountry = useMemo( () => countries.find((c) => c.code === country), [countries, country]);const provinces = selectedCountry?.provinces || [];
// Reset province when country changesconst handleCountryChange = useCallback((newCountry: string) => { setCountry(newCountry); setProvince(''); // Clear province since it may not be valid}, []);
// In the render:{provinces.length > 0 ? ( <select value={province} onChange={(e) => setProvince(e.target.value)}> <option value="">Select...</option> {provinces.map((p) => ( <option key={p.code} value={p.code}>{p.name}</option> ))} </select>) : ( // Free-text input for countries without province list <input type="text" value={province} onChange={(e) => setProvince(e.target.value)} />)}This handles the edge case where a country has no predefined provinces—customers can still enter a region name.
Form Validation
Address forms need validation before submission. We validate required fields and show all errors at once:
const validateForm = (): boolean => { const newErrors: string[] = [];
if (!firstName.trim()) newErrors.push('First name is required.'); if (!lastName.trim()) newErrors.push('Last name is required.'); if (!address1.trim()) newErrors.push('Address is required.'); if (!city.trim()) newErrors.push('City is required.'); if (!zip.trim()) newErrors.push('ZIP/Postal code is required.');
// Province is only required if the country has provinces if (provinces.length > 0 && !province) { newErrors.push('State/Province is required.'); }
if (newErrors.length > 0) { setErrors(newErrors); setFormState('error'); return false; }
return true;};Showing all errors at once (rather than one at a time) is less frustrating for users—they can fix everything in one pass.
The Address Actions Hook
The useAddressActions hook encapsulates all Shopify address operations. Each operation uses FormData to match Shopify’s expected format:
import { useCallback } from 'react';
export function useAddressActions() { const createAddress = useCallback(async (data: AddressFormData) => { const formData = new FormData();
// Shopify expects address[field_name] format formData.append('address[first_name]', data.firstName); formData.append('address[last_name]', data.lastName); formData.append('address[company]', data.company); formData.append('address[address1]', data.address1); formData.append('address[address2]', data.address2); formData.append('address[city]', data.city); formData.append('address[country]', data.country); formData.append('address[province]', data.province); formData.append('address[zip]', data.zip); formData.append('address[phone]', data.phone);
if (data.isDefault) { formData.append('address[default]', '1'); }
const response = await fetch('/account/addresses', { method: 'POST', body: formData, });
if (!response.ok) { throw new Error('Failed to create address.'); }
// Shopify redirects after success - reload to see new address window.location.reload(); }, []);
const updateAddress = useCallback(async (id: number, data: AddressFormData) => { const formData = new FormData();
// _method=put tells Shopify this is an update formData.append('_method', 'put'); formData.append('address[first_name]', data.firstName); // ... other fields same as create
const response = await fetch(`/account/addresses/${id}`, { method: 'POST', // Still POST, but _method makes it an update body: formData, });
if (!response.ok) { throw new Error('Failed to update address.'); }
window.location.reload(); }, []);
const deleteAddress = useCallback(async (id: number) => { const formData = new FormData(); formData.append('_method', 'delete');
const response = await fetch(`/account/addresses/${id}`, { method: 'POST', body: formData, });
if (!response.ok) { throw new Error('Failed to delete address.'); }
window.location.reload(); }, []);
const setDefaultAddress = useCallback(async (id: number) => { const formData = new FormData(); formData.append('_method', 'put'); formData.append('address[default]', '1');
const response = await fetch(`/account/addresses/${id}`, { method: 'POST', body: formData, });
if (!response.ok) { throw new Error('Failed to set default address.'); }
window.location.reload(); }, []);
return { createAddress, updateAddress, deleteAddress, setDefaultAddress };}The window.location.reload() is a trade-off. Shopify’s form endpoints don’t return JSON, so we can’t easily get the new address data. The reload ensures we have fresh, server-validated data.
Delete Confirmation
Deleting an address is destructive and irreversible. Always confirm:
import styles from './DeleteConfirmModal.module.css';
interface DeleteConfirmModalProps { isOpen: boolean; onConfirm: () => void; onCancel: () => void;}
export function DeleteConfirmModal({ isOpen, onConfirm, onCancel }: DeleteConfirmModalProps) { if (!isOpen) return null;
return ( <div className={styles.overlay} onClick={onCancel}> <div className={styles.modal} onClick={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true" aria-labelledby="delete-confirm-title" > <WarningIcon /> <h2 id="delete-confirm-title">Delete Address?</h2> <p> Are you sure you want to delete this address? This action cannot be undone. </p> <div className={styles.actions}> <button type="button" onClick={onCancel}>Cancel</button> <button type="button" onClick={onConfirm} className={styles.deleteButton}> Delete Address </button> </div> </div> </div> );}The role="alertdialog" (rather than role="dialog") signals to assistive technology that this requires the user’s immediate attention.
Liquid Data Bridge
The Liquid template prepares all address data and the country list:
{% comment %} sections/customer-addresses.liquid {% endcomment %}
<div id="address-book-root"></div>
<script type="application/json" id="address-book-data"> { "addresses": [ {%- for address in customer.addresses -%} { "id": {{ address.id }}, "firstName": {{ address.first_name | json }}, "lastName": {{ address.last_name | json }}, "company": {{ address.company | default: "" | json }}, "address1": {{ address.address1 | json }}, "address2": {{ address.address2 | default: "" | json }}, "city": {{ address.city | json }}, "province": {{ address.province | json }}, "provinceCode": {{ address.province_code | json }}, "country": {{ address.country | json }}, "countryCode": {{ address.country_code | json }}, "zip": {{ address.zip | json }}, "phone": {{ address.phone | default: "" | json }}, "isDefault": {{ address.id | equals: customer.default_address.id }} }{% unless forloop.last %},{% endunless %} {%- endfor -%} ], "defaultAddressId": {% if customer.default_address %}{{ customer.default_address.id }}{% else %}null{% endif %}, "countries": [ {%- for country in all_countries -%} { "name": {{ country.name | json }}, "code": {{ country.iso_code | json }}, "provinces": [ {%- for province in country.provinces -%} { "name": {{ province.name | json }}, "code": {{ province.code | json }} }{% unless forloop.last %},{% endunless %} {%- endfor -%} ] }{% unless forloop.last %},{% endunless %} {%- endfor -%} ], "accountUrl": "/account" }</script>
{% schema %}{ "name": "Address Book", "settings": []}{% endschema %}The all_countries Shopify object gives us every country and their provinces, pre-formatted for our dropdowns.
Form Autocomplete Attributes
Browser autocomplete can speed up address entry significantly. Use the correct autocomplete values:
<input type="text" name="firstName" autoComplete="given-name" /><input type="text" name="lastName" autoComplete="family-name" /><input type="text" name="company" autoComplete="organization" /><input type="text" name="address1" autoComplete="address-line1" /><input type="text" name="address2" autoComplete="address-line2" /><input type="text" name="city" autoComplete="address-level2" /><select name="country" autoComplete="country" /><select name="province" autoComplete="address-level1" /><input type="text" name="zip" autoComplete="postal-code" /><input type="tel" name="phone" autoComplete="tel" />These attributes help browsers pre-fill fields from saved addresses, making form completion much faster.
Key Takeaways
-
CRUD pattern: Create, Read, Update, Delete are the four fundamental operations—learn this pattern and you can build any data management UI
-
Modal vs navigation: Modals keep context for quick operations; page navigation is better for complex forms or when you need URL history
-
The
_methodpattern: Shopify uses_method=putand_method=deletebecause HTML forms only support GET/POST—this is common in many frameworks -
Dynamic form fields: Country/province selects need to update together; reset dependent fields when parent changes
-
Confirmation for destructive actions: Always confirm deletes; the brief interruption prevents costly mistakes
-
Page reload trade-off: Without a JSON API, reload is the simplest way to get fresh server data—accept this pragmatism
-
Autocomplete attributes: Use proper
autocompletevalues to leverage browser address autofill -
Validation UX: Show all errors at once, not one at a time; users can fix everything in one pass
This completes Module 12: Customer Account Components. You’ve learned to build authentication forms, account dashboards, order history, and address management—all the essential pieces for a complete customer account experience in a React-powered Shopify theme.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...