CartAddProductButton
Add a product to the cart, including the "no cart yet" path — useAddToCart
names the current cart on the wire and lets the server create one only when
there is none, so a first add is a single round trip and a second one lands in
the cart the first made.
import { CartAddProductButton } from "@evolve-storefront/feature-cart/cart-add-product-button";Renders inside CartProvider.
Design notes
It always targets the active cart, and takes no cart argument. Adding to a specific cart would put a target prop on this button, and every PDP and PLP in the storefront would inherit it — in single-cart mode too, where it means nothing. In multi-cart the flow is switch first, then add.
There is no CartAddProductForm. The only field cart owns is quantity, and
ui/quantity-stepper already clamps it, so a PDP composes the stepper next to
this button. Anything richer is the client's fields with the client's schema:
they own the form and call useAddToCart, which fires the same confirmation seam.
It renders exactly one element and no confirmation of its own — a partial
OUT_OF_STOCK add arrives as warnings on the seam payload, so the app decides
whether that is a toast, a line in a mini-cart, or nothing.
Preview
Usage
The button on its own. Watch the badge: no confirmation UI is wired here.
// It always targets the active cart, and creates one only if there is none.<CartAddProductButton sku={variant.sku} />;Props
| Prop | Type | Description |
|---|---|---|
sku | string | |
isPending? | boolean | The action is running. The button keeps its label, so its width and
accessible name hold, shows a spinner and is |
type? | "reset" | "button" | "submit" | |
variant? | ButtonVariant | |
tone? | ButtonTone | |
isIconOnly? | boolean | The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that. MoreNot inferred from the children. A kebab menu is as often a glyph or an SVG
as an |
asChild? | boolean | |
data-component? | undefined | A button's identity is not a caller's to rename — see |
quantity? | number | |
addOns? | readonly string[] | SKUs of add-ons to add with the product, such as a warranty: one of each per unit, each its own line linked to the product's. |
availableStock? | number | null | Stock for this variant, when the surface knows it. |
availability? | CartVariantAvailability | null | The variant's availability. |
onAdded? | ((result: LineItemsAddedResult) => void) | Confirmation for this call site, overriding |
children? | ReactNode |
Any other props go to the root element, like on a plain HTML element.
Examples
RefusedA refused add is said beside the button, in the cart's own words.
// The alert renders as a sibling of the button; onAdded never runs.<CartAddProductButton sku={variant.sku} onAdded={confirm} />;Product Detail PageA PDP: ui's stepper beside the button.
const [quantity, setQuantity] = useState(1);
// No form, no schema, no second code path — the stepper clamps against `max`.<QuantityStepper value={quantity} onValueChange={setQuantity} min={1} max={variant.availableStock} label={t("quantity-label", { product: variant.name })} decrementLabel={t("quantity-decrease", { product: variant.name })} incrementLabel={t("quantity-increase", { product: variant.name })}/><CartAddProductButton sku={variant.sku} quantity={quantity} availableStock={variant.availableStock}
> {t("add-to-cart")}</CartAddProductButton>;Out Of StockavailableStock={0} disables the button and says why, rather than leaving every caller to derive it from disabled and lose the reason.
// `availableStock={0}` disables it AND labels it. Passing `disabled` yourself// works too, but the shopper is then not told why.<CartAddProductButton sku={variant.sku} availableStock={0} />;Not For SaleA variant without a price for this shopper cannot be added, and the button says so.
<CartAddProductButton sku={variant.sku} availability={variant.availability} />;Confirm With ToastRecipe 1 — a toast, via ui/toaster.
// Set once, on the provider: every add anywhere confirms the same way,// including a reorder flow or your own form calling `useAddToCart`.<CartProvider onLineItemsAdded={({ addedItems, requested, warnings }) => { const name = addedItems[0]?.productName ?? ""; if (warnings.length > 0) { toast.warning(t("added-partial", { product: name })); return; } toast.success(t("added", { product: name, count: requested[0].quantity })); }}> {children}</CartProvider>;Confirm With DialogRecipe 2 — a dialog, via ui/dialog: the natural home for cross-sell, because it has room for a product carousel.
// `onAdded` overrides the provider default for this call site — an override,// not an addition, so this surface does not also open the mini-cart.const [added, setAdded] = useState<LineItemsAddedResult | null>(null);
<CartAddProductButton sku={variant.sku} onAdded={setAdded} /><Dialog open={added !== null} onOpenChange={() => setAdded(null)}> <DialogContent closeLabel={t("close")}> <DialogHeader> <DialogTitle>{t("added-title")}</DialogTitle> <DialogDescription> {added?.addedItems.map((item) => item.productName).join(", ")} </DialogDescription> </DialogHeader> {/* Your recommendations query — this package never fetches products. */} <CrossSellCarousel skus={added?.requested.map((item) => item.sku)} /> <DialogFooter> <Button variant="secondary" onClick={() => setAdded(null)}>{t("keep-shopping")}</Button> <Button>{t("go-to-cart")}</Button> </DialogFooter> </DialogContent></Dialog>;Confirm With Mini CartRecipe 3 — a mini-cart, via ui/sheet.
// The seam hands over the authoritative `cart`, so the panel needs no query.const [added, setAdded] = useState<LineItemsAddedResult | null>(null);const cart = added?.cart;
<CartAddProductButton sku={variant.sku} onAdded={setAdded} /><Sheet open={added !== null} onOpenChange={(open) => !open && setAdded(null)}> <SheetContent closeLabel={t("close")}> <SheetHeader> <SheetTitle>{t("added-title")}</SheetTitle> </SheetHeader> <SheetBody> {/* Or compose `Cart` + `CartLineItems` here for the full rendering. */} <ul className="flex flex-col gap-3"> {cart?.lineItems.map((line) => ( <li key={line.id}>{line.quantity} × {line.productName}</li> ))} </ul> </SheetBody> <SheetFooter> <CheckoutButton total={cart?.total.gross} /> </SheetFooter> </SheetContent></Sheet>;Guest SessionA guest, who has no session until something asks for one — and add to cart is what asks.
// The app's own hook: create a guest session if there is none, then// revalidate auth state so `hasSession` flips and the cart query runs.const ensureSession = useEnsureSession();const { hasToken, validateLocalToken } = useAuth();
<CartProvider hasSession={hasToken} ensureSession={ensureSession} onTokenChange={validateLocalToken}> {children}</CartProvider>;
