Skip to content

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.

liquid
{% 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:

js
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:

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

js
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:

js
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:

js
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:

js
window.Dotshop.pixels.publish('view-content', product);
window.Dotshop.pixels.publish('add-to-cart', selectedVariant);
window.Dotshop.pixels.publish('initiate-checkout', cart);
EventPublish when
view-contentA product page renders.
add-to-cartAn item is successfully added to the cart.
initiate-checkoutThe 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.