Alpine.js Stores for Global State in the Hyva Theme
AI generated
Hyvä
phtml
Hyva Theme, Alpine.js, State Management
Alpine.js Stores for Global State
Theme-wide state without prop drilling

When a cart badge in the header, a sticky add-to-cart bar, and the minicart all need to stay in sync, thinking purely in terms of x-data quickly turns into an awkward mess. Alpine.store() provides a central, reactive store that is available theme-wide, without having to pass state across dozens of component boundaries by hand.

11 min read Alpine.store() State Management Section Data

1. Why prop drilling gets messy fast in Hyva templates

In a classic Alpine setup, state lives locally inside x-data, right on the element that needs it. For isolated UI pieces like a dropdown or an accordion, that works great. But once several structurally distant components need to know the same value, such as the number of cart items shown in the header, in a sticky add-to-cart bar, and in the minicart badge, the approach starts to break down.

The obvious instinct is to pass the value down as a prop from a parent to every child, or to push it through the DOM hierarchy with a custom event. In Hyva templates, which are often assembled from many independently loaded phtml blocks, there simply is no shared parent component that could take on that role. Every prop drilling attempt ends up as a tangle of events that is hard to reason about.


// Anti-pattern: syncing state through scattered custom events
// Header, sticky bar, and minicart each need their own listener
document.addEventListener('cart:item-added', (event) => {
  document.querySelectorAll('[data-cart-badge]').forEach((el) => {
    el.textContent = event.detail.itemCount;
  });
});
document.addEventListener('cart:item-added', (event) => {
  document.querySelector('[data-sticky-bar-count]').textContent = event.detail.itemCount;
});
// Every new place that displays the counter needs yet another listener

2. Alpine.store(): the basics of a global reactive store

Alpine.store() registers a named, reactive object that is reachable via $store.name from any x-data component on the page, regardless of where it sits in the DOM. Unlike local x-data state, a store exists exactly once per page, and any component reading a store value through x-text, x-show, or x-bind automatically re-renders whenever that value changes.

Registration timing matters: stores must be defined before Alpine.start() runs, otherwise early components reach into an empty object. Hyva loads Alpine through its central bootstrap, so store definitions belong in their own script hooked into the alpine:init event, which is guaranteed to fire before Alpine actually starts.


document.addEventListener('alpine:init', () => {
  Alpine.store('cart', {
    itemCount: 0,
    increment(qty = 1) {
      this.itemCount += qty;
    },
    setFromSectionData(count) {
      this.itemCount = count;
    },
  });
});

3. Where to register stores cleanly in a Hyva theme

Store definition code belongs best in its own phtml file included as an early-loaded script block through the default.xml layout handle, so it reliably runs before the first components that consume the store. Important: the inline block must be registered through registerInlineScript() with the Content Security Policy module, otherwise Hyva's CSP module silently blocks the script in production.

For larger themes, a dedicated module under web/js/stores/ with one export per store, registered as an alias in requirejs-config.js and imported from the bootstrap script, pays off. That keeps store definitions testable and avoids scattering store logic across several inline script blocks.


<?php /** @var \Magento\Framework\View\Element\Template $block */
/** @var \Hyva\Theme\ViewModel\HyvaCsp $hyvaCsp */
$hyvaCsp = $block->getData('hyvaCsp');
?>
<script>
  document.addEventListener('alpine:init', () => {
    Alpine.store('cart', { itemCount: <?= (int) $block->getCartItemCount() ?> });
  });
</script>
<?= $hyvaCsp->registerInlineScript() ?>

4. Practical example: a cart store for the item count

The cart store holds the central itemCount counter along with an increment() method called by every add-to-cart action in the theme, whether triggered from the product page, from a category quick view, or from the minicart itself. The header badge, the sticky bar, and the minicart icon all read the same $store.cart.itemCount and stay in sync without a single custom event passing between them.

The actual trigger remains Magento's add-to-cart Ajax call, the store only handles UI-side synchronization. The calling component only invokes $store.cart.increment(qty) once the Ajax call comes back successfully, so the display never shows a value that does not actually exist on the server.


<div x-data class="relative">
  <button type="button" aria-label="Cart">
    <span x-show="$store.cart.itemCount > 0"
          x-text="$store.cart.itemCount"
          class="absolute -top-2 -right-2 rounded-full bg-orange-600 px-1.5 text-xs text-white"></span>
  </button>
</div>

<script>
  function addToCartHandler(productId, qty) {
    return fetch('/checkout/cart/add', { method: 'POST', body: buildFormData(productId, qty) })
      .then((response) => {
        if (response.ok) {
          Alpine.store('cart').increment(qty);
        }
        return response;
      });
  }
</script>

5. Coordinating with Magento Section Data and Customer Data

Magento's Section Data mechanism remains the actual source of truth for cart and customer data in Hyva, because it delivers server-side, private content through the private content cache, including correct invalidation when the full page cache is active. An Alpine store must not replace that role, it should be understood as a fast, client-side projection of the most recently known section data.

In practice that means: after every section data reload triggered by Hyva's equivalent of customer-data.js, a small bridge function reads the current values out of section storage and writes them into the matching Alpine store. That way the store always stays a mirror of the real, server-authorized data, never an independent source.


document.addEventListener('private-content-loaded', (event) => {
  const cartSection = event.detail.data.cart;
  if (cartSection) {
    Alpine.store('cart').setFromSectionData(cartSection.summary_count);
  }
  const wishlistSection = event.detail.data.wishlist;
  if (wishlistSection) {
    Alpine.store('wishlist').setFromSectionData(wishlistSection.items);
  }
});

6. Wishlist status as a second store: reactive hearts without a reload

A second typical use case is per-product wishlist status. Instead of checking for every single product tile whether an item is already on the wishlist, a wishlist store holds a set of product IDs, filled once from section data when the page loads, and then queried by every product tile through x-bind:class.

The real payoff shows up when adding items: if a customer clicks the heart icon on several tiles at once in a category view, all affected icons update instantly through the same store, even though only one single component actually triggered the Ajax call.


document.addEventListener('alpine:init', () => {
  Alpine.store('wishlist', {
    ids: new Set(),
    setFromSectionData(items) {
      this.ids = new Set(items.map((item) => item.product_id));
    },
    has(productId) {
      return this.ids.has(productId);
    },
    toggle(productId) {
      this.ids.has(productId) ? this.ids.delete(productId) : this.ids.add(productId);
      this.ids = new Set(this.ids);
    },
  });
});

7. A persistence strategy across page navigations

Hyva is deliberately not a single page application, every link click triggers a full page load, which means every Alpine store gets completely reinitialized on every navigation. Unlike in an SPA, the official Alpine Persist plugin with its localStorage binding therefore only adds real value for a handful of UI states, such as whether a filter panel was collapsed.

For cart and wishlist data, persisting through localStorage is actually risky, since it can drift out of sync with the server-side state, for instance after a logout in a second tab. The more robust strategy is to freshly initialize the store from current section data on every page load, rather than artificially keeping it alive through browser storage.


// A reasonable use of Alpine Persist: pure UI preference, no server relation
Alpine.store('filterPanel', {
  collapsed: Alpine.$persist(false).as('filterPanelCollapsed'),
  toggle() {
    this.collapsed = !this.collapsed;
  },
});
// Cart and wishlist stores deliberately skip $persist, always fresh from section data

8. Performance considerations: reactivity without unnecessary re-renders

Because potentially many components listen to the same store, the granularity of the observed values is worth a closer look. Rendering the entire store object through x-text, instead of binding to a single field, forces a recomputation of every dependent expression in the DOM on every tiny change, even where the displayed value never actually changed.

Another pitfall is replacing an entire store object instead of mutating individual fields. Alpine's reactivity relies on proxy traps at the object and array level, replacing the whole object still works, but can cause noticeable delays with deeply nested structures when many components need to re-evaluate at once.

9. Debugging and common mistakes in everyday store use

The most common mistake is accessing a store before it was registered, usually because the store script sits after rather than before the consuming components in the DOM, or because the registerInlineScript() call sits in the wrong layout handle. An error about reading a property of undefined on the very first click is almost always a sign of exactly this timing problem in Hyva projects.

For troubleshooting itself, the browser console is usually enough: Alpine.store('cart') returns the live current store object and can be manipulated directly to test UI reactions without a real Ajax call. The official Alpine devtools extension additionally shows every registered store in its own panel, including live values on every interaction.

Approach Scope Persists across reload When it fits CSP compatibility in Hyva
Local x-data state Single component No, lost on reload Isolated UI elements like dropdowns No issues, no special rules
Alpine.store() Theme-wide, every component No, refilled from section data Cart item count, wishlist status, UI flags No issues once registered via registerInlineScript()
Magento Section Data Theme-wide, server-authorized Yes, through the private content cache Actual data source for customer and cart data No issues, standard mechanism
Custom events Point to point between components No One-off notifications without lasting state No issues, but gets confusing with many listeners
Alpine Persist plugin Theme-wide, stored in the browser Yes, across page loads and sessions Pure UI preferences with no server relation No issues, but be careful with sensitive data

Mironsoft

Hyvä theme development and Luma migration

Still running Luma, or a Hyvä theme that just doesn't feel right?

We build Hyvä themes for Magento from scratch or migrate existing Luma shops cleanly, with Tailwind CSS, Alpine.js, and none of the unnecessary JavaScript baggage.

Luma-to-Hyvä Migration

Move an existing shop to Hyvä in a structured way, without losing functionality.

Custom Theme Development

Build a custom Hyvä theme from scratch based on your design.

Performance Optimization

Improve Core Web Vitals and load times in the Hyvä frontend with purpose.

10. Summary

Alpine Stores in Hyva

A store instead of prop drilling

Alpine.store() makes cart item count and wishlist status available theme-wide without passing state across component boundaries.

Section Data stays the source of truth

The store only mirrors the most recently known, server-authorized section data and never replaces it.

Use persistence deliberately and sparingly

Since Hyva is a multi-page application, cart state belongs freshly filled from section data on every load, not kept alive in localStorage.

Bind granularly for performance

Targeted bindings on individual store fields, rather than whole objects, avoid unnecessary DOM recomputation.

11. FAQ: Alpine Stores in Hyva

1What is the difference between local x-data state and an Alpine store?
Local x-data state belongs to exactly one component and disappears once that component is removed from the DOM. An Alpine store exists exactly once per page and is reachable via $store.name from any component, regardless of its position in the DOM.
2Do I need to register Alpine.store() before Alpine.start()?
Yes, stores must be defined before Alpine starts, otherwise the first components reach into an empty object. In Hyva that is reliably achieved by hooking the registration to the alpine:init event, which is guaranteed to fire before the actual start.
3Does an Alpine store replace Magento's Section Data mechanism?
No. Section data remains the server-authorized source of truth with correct cache invalidation. The Alpine store is only a fast, client-side projection of that data for reactive UI updates without extra Ajax calls.
4How do I sync a cart icon in the header and in a sticky bar without custom events?
Both components simply bind to the same $store.cart.itemCount. Once the value changes centrally in the store, every bound component updates automatically, with no events needing to travel between them.
5Should I store cart data with the Alpine Persist plugin in localStorage?
Better not to. Cart and wishlist data can drift out of sync with the server, for instance after a logout in another tab. It is more robust to refill the store fresh from current section data on every page load.
6How do I register a store in a CSP compliant way in Hyva?
The store definition sits in an inline script block inside a phtml file, immediately followed by a call to $hyvaCsp->registerInlineScript(). Without that call, Hyva's CSP module silently blocks the script in production with no visible error in the UI.
7How do I debug a store in the browser console?
Typing Alpine.store('cart') into the console returns the live current store object and can be manipulated directly, for instance Alpine.store('cart').increment(3), to check UI reactions without a real Ajax call.
8Why do I get an error about reading a property of undefined when accessing a store?
That is almost always a timing problem: a component tries to access $store.cart before the store was registered. Check whether the store script sits before the consuming components in the DOM and lives in the correct layout handle.
9Can I use several stores in parallel in the same theme?
Yes, that is even recommended. Separate stores for cart, wishlist, and pure UI state like a collapsed filter panel keep responsibilities cleanly separated and avoid a single, confusing mega object.
10Does an Alpine store affect the full page cache or server side rendering?
No, an Alpine store exists exclusively in the browser and has no effect on server side rendering or the full page cache. It gets filled client-side on page load from section data that was already delivered.