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.
Table of Contents
- 1. Why prop drilling gets messy fast in Hyva templates
- 2. Alpine.store(): the basics of a global reactive store
- 3. Where to register stores cleanly in a Hyva theme
- 4. Practical example: a cart store for the item count
- 5. Coordinating with Magento Section Data and Customer Data
- 6. Wishlist status as a second store: reactive hearts without a reload
- 7. A persistence strategy across page navigations
- 8. Performance considerations: reactivity without unnecessary re-renders
- 9. Debugging and common mistakes in everyday store use
- 10. Summary
- 11. FAQ
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.