Skip to content

Commit 003ddb4

Browse files
committed
feat(shop): fully optimistic add-to-cart with product snapshot
The PDP now passes a line snapshot (title, handle, price, image, options) through to useAddToCart. onMutate uses it to construct a complete optimistic cart line with a temporary ID, so the item appears in the drawer in the same frame as the click. onSuccess reconciles with the real server response (permanent line ID, exact totals). If the same variant is already in the cart, onMutate bumps its quantity instead of adding a duplicate line.
1 parent a7246a7 commit 003ddb4

File tree

2 files changed

+101
-9
lines changed

2 files changed

+101
-9
lines changed

src/hooks/useCart.ts

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
removeDiscountCode,
88
updateCartLine,
99
} from '~/utils/shop.functions'
10-
import type { CartDetail } from '~/utils/shopify-queries'
10+
import type { CartDetail, CartLineDetail } from '~/utils/shopify-queries'
1111

1212
/**
1313
* Shared React Query key for the current user's cart.
@@ -72,12 +72,37 @@ export function useCart() {
7272
}
7373
}
7474

75+
/**
76+
* Snapshot of the product/variant from the PDP, passed through to
77+
* onMutate so a full optimistic cart line can be rendered instantly.
78+
*/
79+
type AddToCartLineSnapshot = {
80+
productTitle: string
81+
productHandle: string
82+
variantTitle: string
83+
price: { amount: string; currencyCode: string }
84+
image: {
85+
url: string
86+
altText?: string | null
87+
width?: number | null
88+
height?: number | null
89+
} | null
90+
selectedOptions: Array<{ name: string; value: string }>
91+
}
92+
93+
type AddToCartInput = {
94+
variantId: string
95+
quantity?: number
96+
/** Product snapshot for optimistic line rendering. */
97+
line?: AddToCartLineSnapshot
98+
}
99+
75100
export function useAddToCart() {
76101
const qc = useQueryClient()
77102

78103
return useMutation({
79104
mutationKey: CART_MUTATION_KEY,
80-
mutationFn: (input: { variantId: string; quantity?: number }) =>
105+
mutationFn: (input: AddToCartInput) =>
81106
addToCart({
82107
data: { variantId: input.variantId, quantity: input.quantity ?? 1 },
83108
}),
@@ -86,12 +111,64 @@ export function useAddToCart() {
86111
const quantity = input.quantity ?? 1
87112
await qc.cancelQueries({ queryKey: CART_QUERY_KEY })
88113
const previous = qc.getQueryData<CartDetail | null>(CART_QUERY_KEY)
89-
if (previous) {
114+
115+
if (previous && input.line) {
116+
const snap = input.line
117+
118+
// Does this variant already have a line in the cart?
119+
const existingIdx = previous.lines.nodes.findIndex(
120+
(l) => l.merchandise.id === input.variantId,
121+
)
122+
123+
let nextLines: CartDetail['lines']['nodes']
124+
if (existingIdx >= 0) {
125+
nextLines = previous.lines.nodes.map((l, i) =>
126+
i === existingIdx
127+
? { ...l, quantity: l.quantity + quantity }
128+
: l,
129+
)
130+
} else {
131+
const lineTotal = String(Number(snap.price.amount) * quantity)
132+
nextLines = [
133+
...previous.lines.nodes,
134+
{
135+
id: `optimistic-${Date.now()}`,
136+
quantity,
137+
merchandise: {
138+
id: input.variantId,
139+
title: snap.variantTitle,
140+
availableForSale: true,
141+
selectedOptions: snap.selectedOptions,
142+
price: snap.price,
143+
image: snap.image,
144+
product: {
145+
handle: snap.productHandle,
146+
title: snap.productTitle,
147+
},
148+
},
149+
cost: {
150+
totalAmount: {
151+
amount: lineTotal,
152+
currencyCode: snap.price.currencyCode,
153+
},
154+
},
155+
} as CartLineDetail,
156+
]
157+
}
158+
159+
qc.setQueryData<CartDetail | null>(CART_QUERY_KEY, {
160+
...previous,
161+
totalQuantity: nextLines.reduce((s, l) => s + l.quantity, 0),
162+
lines: { ...previous.lines, nodes: nextLines },
163+
})
164+
} else if (previous) {
165+
// No snapshot — fall back to just bumping the count
90166
qc.setQueryData<CartDetail | null>(CART_QUERY_KEY, {
91167
...previous,
92168
totalQuantity: (previous.totalQuantity ?? 0) + quantity,
93169
})
94170
}
171+
95172
return { previous }
96173
},
97174

@@ -100,10 +177,8 @@ export function useAddToCart() {
100177
qc.setQueryData(CART_QUERY_KEY, ctx.previous)
101178
},
102179

103-
// Add-to-cart needs onSuccess to populate the new line item in the
104-
// cache — onMutate can only bump totalQuantity since it doesn't have
105-
// the full product data. Unlike remove/update, rapid-fire adds are
106-
// rare, and the worst case is a brief badge-count fluctuation.
180+
// Reconcile: replace the optimistic line (temporary ID, approximate
181+
// totals) with the real server response.
107182
onSuccess: (cart) => {
108183
qc.setQueryData(CART_QUERY_KEY, cart)
109184
},

src/routes/shop.products.$handle.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,11 @@ function ProductPage() {
112112
onChange={(n) => setQuantity(Math.max(1, n))}
113113
/>
114114

115-
<AddToCartButton variant={selectedVariant} quantity={quantity} />
115+
<AddToCartButton
116+
variant={selectedVariant}
117+
quantity={quantity}
118+
product={product}
119+
/>
116120

117121
{product.descriptionHtml ? (
118122
<div
@@ -302,9 +306,11 @@ function findMatchingVariant(
302306
function AddToCartButton({
303307
variant,
304308
quantity,
309+
product,
305310
}: {
306311
variant: ProductDetailVariant | undefined
307312
quantity: number
313+
product: ProductDetail
308314
}) {
309315
const addToCart = useAddToCart()
310316
const openDrawer = useCartDrawerStore((s) => s.openDrawer)
@@ -335,7 +341,18 @@ function AddToCartButton({
335341
onClick={() => {
336342
if (!variant) return
337343
openDrawer()
338-
addToCart.mutate({ variantId: variant.id, quantity })
344+
addToCart.mutate({
345+
variantId: variant.id,
346+
quantity,
347+
line: {
348+
productTitle: product.title,
349+
productHandle: product.handle,
350+
variantTitle: variant.title,
351+
price: variant.price,
352+
image: variant.image,
353+
selectedOptions: variant.selectedOptions,
354+
},
355+
})
339356
}}
340357
className={twMerge(
341358
'mt-2 px-6 py-3 rounded-lg font-semibold transition-colors',

0 commit comments

Comments
 (0)