Customer Account Components Intermediate 10 min read

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
└── AddressForm

CRUD Operations with Shopify

Shopify doesn’t provide a JSON API for customer addresses in themes. Instead, we use traditional form submissions to these endpoints:

OperationMethodEndpoint
CreatePOST/account/addresses
UpdatePOST + _method=put/account/addresses/{id}
DeletePOST + _method=delete/account/addresses/{id}
Set DefaultPOST + _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/StateSourceLiquid Field
addressesJSON script elementcustomer.addresses
defaultAddressIdJSON script elementcustomer.default_address.id
countriesJSON script elementall_countries
formActionConstant/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:

src/types/address.ts
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
src/components/customer/AddressBook/AddressBook.tsx
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:

src/components/customer/AddressBook/AddressCard.tsx
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.

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
src/components/customer/AddressBook/AddressFormModal.tsx
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 component
const [country, setCountry] = useState(address?.countryCode || 'US');
const [province, setProvince] = useState(address?.provinceCode || '');
// Find the selected country to get its provinces
const selectedCountry = useMemo(
() => countries.find((c) => c.code === country),
[countries, country]
);
const provinces = selectedCountry?.provinces || [];
// Reset province when country changes
const 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:

src/components/customer/AddressBook/useAddressActions.ts
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:

src/components/customer/AddressBook/DeleteConfirmModal.tsx
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

  1. CRUD pattern: Create, Read, Update, Delete are the four fundamental operations—learn this pattern and you can build any data management UI

  2. Modal vs navigation: Modals keep context for quick operations; page navigation is better for complex forms or when you need URL history

  3. The _method pattern: Shopify uses _method=put and _method=delete because HTML forms only support GET/POST—this is common in many frameworks

  4. Dynamic form fields: Country/province selects need to update together; reset dependent fields when parent changes

  5. Confirmation for destructive actions: Always confirm deletes; the brief interruption prevents costly mistakes

  6. Page reload trade-off: Without a JSON API, reload is the simplest way to get fresh server data—accept this pragmatism

  7. Autocomplete attributes: Use proper autocomplete values to leverage browser address autofill

  8. 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...