PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Hyvä advanced patterns – Alpine.js Store, events, lazy loading, reusable components

by Henryk Tews / Tuesday, 17 February 2026 / Published in Magento 2

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.

About Henryk Tews

What you can read next

Strategy pattern in PHP – and how Magento 2 uses it in pricing
Xdebug – configuration, PHPStorm, debugging Magento plugins

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}