Skip to main content

Configure actions

Every Liquid storefront supports actions out of the box, and every action ships with a default implementation. An app can call updateCart on your theme and it works, even if you've never touched any of this.

That default falls back to a full page reload when it doesn't recognize how your theme renders its cart. You can configure an action to replace the default with your own code, so the same call opens your drawer or re-renders your section instead.


Anchor to What the defaults doWhat the defaults do

Without a configuration, each action runs its default behavior:

  • updateCart: Writes to the Storefront API, then refreshes the cart in place on a theme it recognizes. If it recognizes none, then it falls back to a full page reload.
  • openCart: Opens a cart drawer it finds on the page, and otherwise navigates to /cart.
  • getCart: Reads the current cart from the Storefront API without affecting the page.
Note

getCart isn't configurable. Calling Shopify.actions.getCart.configure(...) is a TypeScript error and a runtime TypeError.

Anchor to How the defaults recognize a themeHow the defaults recognize a theme

If your theme is based on Horizon or Dawn, then the defaults refresh your cart in place, so a call doesn't reload the page even before you configure anything.

Your theme keeps that behavior after you fork and rename it, because recognition is based on your cart's code rather than the theme's name. updateCart looks for two shapes:

PatternWhat it looks forHow it refreshes
Horizon-styleA <cart-icon> with a renderCartBubble() method, and a <cart-items-component> with updateQuantity()Dispatches a cart:update event on document for those components to pick up
Dawn-styleA cart element such as cart-drawer or cart-items whose prototype has getSectionsToRender()Asks each one which sections it needs, fetches them, and swaps the HTML in

openCart looks for an open() method, first on <cart-drawer-component> and then on <cart-drawer>. Horizon defines <cart-drawer-component> but keeps open() on a separate drawer element, so it configures openCart rather than relying on the default.

If your theme renders its cart some other way, then the defaults fall back to a full page reload, and configuring an action is what avoids that.


Anchor to Configuring an actionConfiguring an action

You can register a configuration inside a DOMContentLoaded listener. Put it above {{ content_for_header }} in your layout file, so yours runs before any app code:

document.addEventListener('DOMContentLoaded', () => {
Shopify.actions.updateCart.configure({
eventTarget: () => document.querySelector('cart-items'),
});
});

Only the first configure call on an action takes effect. Later calls return false and change nothing, so putting yours above {{ content_for_header }} makes sure it runs first.

A configuration accepts two options, and they're two ways of updating your UI. If your theme already has components that re-render when they receive standard events, you can point eventTarget at them and let your existing code do the work. If you'd rather run your own code, such as re-rendering a section, you can write a handler. You can use both together.

Anchor to configuration optionsConfiguration options

Anchor to eventTarget
eventTarget
(meta: ) => EventTarget | null

A function that returns the element that auto-emitted events dispatch from. Required when you configure updateCart, which is the only action that emits events. openCart doesn't accept it. Returning null dispatches from document.

Anchor to handler
handler
(defaultHandler, ...args) => Promise<Result>

Replaces the action's behavior. Call defaultHandler() to run Shopify's implementation first, or skip it to replace the behavior outright.


eventTarget is a function that returns the element that auto-emitted events dispatch from. updateCart is the only action that auto-emits events, so it's the only action that requires this.

You point it at the element your components listen on. If those components re-render when they get the events, your cart updates in place.

Shopify still writes to the Storefront API. Any configuration at all turns off the page reload fallback.

The example below sends each event to the component that owns it. Note changes go to the cart note, discount changes to the discount form, adds to the product form the buyer used, and anything else to the cart:

Shopify.actions.updateCart.configure({
eventTarget: (meta) => {
if (meta.type === 'shopify:cart:note-update') return document.querySelector('cart-note');
if (meta.type === 'shopify:cart:discount-update') return document.querySelector('cart-discount');
if (meta.type === 'shopify:cart:lines-update' && meta.action === 'add') {
return document.querySelector('product-form');
}
return document.querySelector('cart-items');
},
});

That routing is possible because the function receives a meta object describing the event it's about to emit:

'shopify:cart:lines-update' | 'shopify:cart:note-update' | 'shopify:cart:discount-update' | 'shopify:cart:error'
required

The event the action is about to emit.

Anchor to action
action
'add' | 'remove' | 'update'

Which kind of line change is happening. Only present when type is shopify:cart:lines-update.

If the function returns null, then the event dispatches from document instead.


handler is an optional async function that runs in place of the default handler.

You can write a handler when your storefront updates its UI in code rather than through event listeners. A handler also covers work a re-render can't do, like fetching and swapping section HTML. For openCart it's the only option, because the handler is the entire behavior.

The example below keeps Shopify's cart write by calling defaultHandler() first, then fetches the cart drawer section and swaps in the new HTML. The same listener configures openCart to open that drawer:

document.addEventListener('DOMContentLoaded', () => {
Shopify.actions.updateCart.configure({
eventTarget: (meta) => {
if (meta.type === 'shopify:cart:lines-update' && meta.action === 'add') {
return document.querySelector('product-form');
}
return document.querySelector('cart-items');
},
async handler(defaultHandler, payload) {
const result = await defaultHandler();
const response = await fetch(window.location.pathname + '?sections=cart-drawer');
const { 'cart-drawer': html } = await response.json();
document.querySelector('cart-drawer').innerHTML = html;
return result;
},
});

Shopify.actions.openCart.configure({
handler() {
document.querySelector('cart-drawer')?.open();
},
});
});

A handler receives defaultHandler first, followed by the same arguments the caller passed to the action:

Anchor to defaultHandler
defaultHandler
() => Promise<Result>
required

Runs Shopify's implementation and resolves with the result the action would have returned on its own.

Anchor to payload
payload

The payload the caller passed. openCart takes no payload.

Anchor to options
options

The options the caller passed, such as an AbortSignal. openCart takes no options.

A handler that never calls defaultHandler() replaces the behavior completely, so it must also return the shape callers expect.


updateCart emits the matching standard events as the call starts, before the cart is written. Each event carries a promise that settles with the outcome, so a listener can show a pending state. This happens whether or not the storefront configured the action, and without an eventTarget they dispatch from document:

ActionEvents auto-emitted
openCartNone
getCartNone
updateCart

An aborted call and an invalid payload don't emit shopify:cart:error.

eventTarget runs once per event, so each event dispatches from the element the function returns for that event. The action emits them itself, so your theme's own dispatch code only has to cover changes that happen outside an action.


Anchor to Preventing double UI updatesPreventing double UI updates

The action emits its cart events whether or not you write a handler. If your handler re-renders the cart and your components also re-render when they receive shopify:cart:lines-update, then both paths run and the UI updates twice.

A handler can add its own fields to the result, and listeners read them from the event's promise. A listener can then see what the handler already covered and skip its own render. This detail sits on the result, and is separate from the detail a caller attaches to the events:

// In the handler: mark what it already rendered.
async handler(defaultHandler, payload) {
const result = await defaultHandler();
await renderCartSections();
return { ...result, detail: { handledBy: 'theme' } };
}

// In the component: skip the render the handler covered.
event.promise.then(({ detail }) => {
if (detail?.handledBy === 'theme') return;
morphSection(sectionId);
});

A storefront whose components don't listen for cart events doesn't hit this, because the handler is the only thing updating the UI.



Was this page helpful?