Events and actions
A storefront theme has three separate channels for behaviour: actions that change store state, events that let parts of the theme react to each other, and pixel events that report what the customer did. Keep them distinct.
Actions
Prefer forms where they work
The {% form %} tag posts to the storefront and reloads the page. It needs no JavaScript, which means it still works while scripts are loading or if they fail. Use it for add-to-cart on a standard product page.
{% form 'product' %}
<input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
<input type="number" name="quantity" value="1">
<button {% unless product.available %}disabled{% endunless %}>
{{ 'product.add_to_cart' | t }}
</button>
{% endform %}Use the SDK for interactive flows
When a reload would break the experience, such as a cart drawer or a variant picker that updates in place, use the YouCan JS SDK:
await youcanjs.cart.addItem({
productVariantId: variantId,
quantity: 1,
});The SDK covers cart (addItem, updateItem, removeItem), checkout (applyCoupon, removeCoupons, placeExpressCheckoutOrder), products (fetchReviews, submitReview), upsells (answer), and location lookups (getStoreMarketCountries, getCountryRegions, getCountryCities).
Pick one approach per theme. Mixing a form post and an SDK call for the same action produces two different states to keep in sync.
Handle failure
Every SDK call talks to the network and can fail. Catch it and tell the customer, rather than leaving a button spinning:
try {
await youcanjs.cart.addItem({ productVariantId, quantity });
} catch (error) {
toast.show(window.errorStrings.cart, 'error');
}Theme events
Sections don't know about each other. When adding to the cart should update a badge in the header, a drawer, and a subtotal in a third section, a small publish and subscribe module is cleaner than having each component reach into the others.
const subscribers = {};
function subscribe(eventName, callback) {
subscribers[eventName] = [...(subscribers[eventName] ?? []), callback];
return function unsubscribe() {
subscribers[eventName] = subscribers[eventName].filter(cb => cb !== callback);
};
}
function publish(eventName, data) {
subscribers[eventName]?.forEach(callback => callback(data));
}Name events in one place so a typo can't silently unsubscribe a component:
const PUB_SUB_EVENTS = {
cartUpdate: 'cart/update',
cartError: 'cart/error',
couponUpdate: 'coupon/update',
};Publish after the action succeeds, and pass the new state rather than making subscribers refetch it:
const cart = await youcanjs.cart.addItem({ productVariantId, quantity });
publish(PUB_SUB_EVENTS.cartUpdate, { cart, source: 'product-form' });Include a source so a component can ignore the event it caused itself.
Keep the unsubscribe function and call it when a component is torn down. Sections are removed and re-rendered while a seller is customizing, and subscribers that outlive their elements leak and throw.
Pixel events
Storefront analytics and marketing pixels listen on a separate channel, published through window.Dotshop.pixels:
window.Dotshop.pixels.publish('view-content', product);
window.Dotshop.pixels.publish('add-to-cart', selectedVariant);
window.Dotshop.pixels.publish('initiate-checkout', cart);| Event | Publish when |
|---|---|
view-content | A product page renders. |
add-to-cart | An item is successfully added to the cart. |
initiate-checkout | The customer moves from the cart to checkout. |
Publish these once, at the point the thing actually happened, and only after it succeeded. Firing add-to-cart on the click rather than on the response inflates every seller's conversion data.
Don't route theme state through the pixel channel, and don't route analytics through your own pub/sub. They have different consumers and different lifetimes.