Once you know the Hyvä basics – PHTML templates, Alpine.js x-data, Tailwind classes – the next step is advanced patterns that make complex features maintainable. Alpine.js Store for shared state, custom events for cross-component communication, lazy loading for heavy widgets, and reusable component patterns are what separate a well-architected Hyvä theme from a pile of inline scripts.
Alpine.js Store – shared state across components
// Shared cart state accessible from any component on the page
// Define in a layout block loaded on every page
document.addEventListener('alpine:init', () => {
Alpine.store('cart', {
count: 0,
items: [],
subtotal: 0,
loading: false,
// Initialise from server-rendered data
init() {
const cartData = window.hyvaCartData ?? {};
this.count = cartData.count ?? 0;
this.subtotal = cartData.subtotal ?? 0;
},
async addItem(sku, qty, formKey) {
this.loading = true;
try {
const res = await fetch('/checkout/cart/add', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ product: sku, qty, form_key: formKey }),
});
if (res.ok) {
// Trigger Hyvä customer section reload
window.dispatchEvent(new CustomEvent('reload-customer-section-data'));
}
} finally {
this.loading = false;
}
},
});
});
// Listen for Hyvä section updates to sync the store
window.addEventListener('private-content-loaded', (event) => {
const cart = event.detail?.data?.cart;
if (cart) {
Alpine.store('cart').count = cart.summary_count ?? 0;
Alpine.store('cart').subtotal = cart.subtotal_amount ?? 0;
}
});
<!-- Use the store from any component -->
<div x-data>
<!-- Cart count in header -->
<span x-text="$store.cart.count" class="badge"></span>
<!-- Loading state anywhere -->
<div x-show="$store.cart.loading" class="spinner"></div>
</div>
<!-- Add to cart button -->
<button
x-data
@click="$store.cart.addItem('<?= $sku ?>', 1, hyva.getFormKey())"
:disabled="$store.cart.loading"
class="btn btn-primary">
Add to Cart
</button>
Custom events – cross-component communication
// Dispatching a custom event (in any component)
window.dispatchEvent(new CustomEvent('product-added-to-wishlist', {
detail: { sku: 'MG-001', productName: 'Widget' }
}));
// Listening in another component
function initWishlistNotification() {
return {
show: false,
message: '',
init() {
window.addEventListener('product-added-to-wishlist', (event) => {
this.message = `${event.detail.productName} added to wishlist`;
this.show = true;
setTimeout(() => { this.show = false; }, 3000);
});
}
};
}
<!-- Toast notification component - listens for events from anywhere -->
<div x-data="initWishlistNotification()"
x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-end="opacity-0"
class="fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded shadow-lg z-50">
<span x-text="message"></span>
</div>
Lazy loading heavy widgets
// Load a heavy component only when it enters the viewport
function initLazyReviews(productId) {
return {
loaded: false,
loading: false,
reviews: [],
init() {
// IntersectionObserver - load when element becomes visible
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.loaded) {
this.fetchReviews();
observer.disconnect();
}
}, { rootMargin: '200px' }); // start loading 200px before visible
observer.observe(this.$el);
},
async fetchReviews() {
this.loading = true;
try {
const res = await fetch(`/rest/V1/products/${productId}/reviews`);
this.reviews = await res.json();
this.loaded = true;
} finally {
this.loading = false;
}
}
};
}
<!-- Lazy-loaded reviews section -->
<div x-data="initLazyReviews(<?= (int)$product->getId() ?>)"
class="mt-8">
<!-- Skeleton loader shown before data arrives -->
<template x-if="loading">
<div class="animate-pulse space-y-4">
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 rounded w-1/2"></div>
</div>
</template>
<template x-if="loaded && reviews.length === 0">
<p class="text-gray-500"><?= __('No reviews yet.') ?></p>
</template>
<template x-for="review in reviews" :key="review.id">
<div class="border-b py-4">
<p class="font-medium" x-text="review.nickname"></p>
<p class="text-gray-700" x-text="review.detail"></p>
</div>
</template>
</div>
Reusable Alpine.js components
// Pattern: register reusable component factory functions globally
// Define in a layout block, use in any PHTML template
function initQuantitySelector(options = {}) {
return {
qty: options.initial ?? 1,
min: options.min ?? 1,
max: options.max ?? 999,
increment() {
if (this.qty < this.max) this.qty++;
},
decrement() {
if (this.qty > this.min) this.qty--;
},
validate() {
this.qty = Math.min(Math.max(parseInt(this.qty) || this.min, this.min), this.max);
}
};
}
function initToggle(defaultOpen = false) {
return { open: defaultOpen, toggle() { this.open = !this.open; } };
}
function initDropdown() {
return {
open: false,
toggle() { this.open = !this.open; },
close() { this.open = false; },
init() {
// Close when clicking outside
this.$watch('open', (value) => {
if (value) {
const handler = (e) => {
if (!this.$el.contains(e.target)) {
this.open = false;
document.removeEventListener('click', handler);
}
};
setTimeout(() => document.addEventListener('click', handler), 0);
}
});
}
};
}
<?php
$maxQty = (int)$block->getStockItem()->getMaxSaleQty();
?>
<!-- Reusable quantity selector -->
<div x-data="initQuantitySelector({ initial: 1, max: <?= $maxQty ?> })">
<button @click="decrement" :disabled="qty <= min" class="btn btn-sm">-</button>
<input type="number" x-model.number="qty" @change="validate"
:min="min" :max="max" class="w-16 text-center border rounded mx-1">
<button @click="increment" :disabled="qty >= max" class="btn btn-sm">+</button>
</div>
Summary
Advanced Hyvä patterns follow a clear architecture: Alpine Store for global shared state (cart, wishlist), custom events for cross-component communication without tight coupling, IntersectionObserver for lazy loading heavy features below the fold, and globally registered factory functions for reusable widgets. These patterns keep PHTML templates readable and prevent the “inline scripts everywhere” anti-pattern that Hyvä’s simplicity can encourage.
