Alpine.js in Hyvä Theme: Interactive Magento 2 Components Without jQuery Explained
· Reading time: approx. 20 minutes · Categories: Magento 2, Hyvä Theme, JavaScript
Alpine.js in Hyvä Theme
Fully Explained
x-data, x-show, x-for, $store and the component pattern, every Alpine.js concept explained hands-on for Magento 2 Hyvä Theme development without jQuery and Knockout.js.
Why Alpine.js Powers the Hyvä Theme Frontend
Anyone moving from a classic Magento 2 Luma theme to Hyvä inevitably runs into Alpine.js. No more Knockout.js, no RequireJS, no UI components from the Magento core. Instead, a lean, declarative JavaScript framework that lives directly inside the HTML markup, similar to how Tailwind CSS handles styling.
Alpine.js was released in 2019 by Caleb Porzio and follows a clear philosophy: interactivity should be defined where it is visible, in the HTML, not in a separate JavaScript file. The framework is 15 KB in size, has no dependencies, and can be learned in five minutes. For the Hyvä Theme it is the perfect complement to Tailwind CSS: Tailwind handles the visual appearance, Alpine.js handles the interactive behavior.
This tutorial explains every relevant Alpine.js concept for Hyvä Theme development: from the core directives, through the component pattern, all the way to integration with the Magento 2 REST API and Hyvä's own event system. The examples are taken directly from real-world Magento 2 practice and work exactly like this in production Hyvä projects.
- 1. What is Alpine.js?
- 2. The core directives
- 3. More directives and magic properties
- 4. Alpine.js in Hyvä phtml templates
- 5. The component pattern
- 6. Global state with $store
- 7. Alpine.js with the Magento 2 REST API
- 8. Hyvä events and $dispatch
- 9. Common mistakes and fixes
- 10. Alpine.js vs. Knockout.js
- 11. Summary
- 12. FAQ
1. What is Alpine.js?
Alpine.js is a lightweight JavaScript framework that enables reactive data binding and declarative DOM manipulation directly within HTML attributes. It requires no build toolchain, no bundling, and no additional dependencies. The entire framework is a single JavaScript file, included either as a <script> tag or installed via NPM.
The philosophical kinship with Vue.js is no coincidence. Caleb Porzio has explicitly described Alpine.js as "Vue for people who don't want a build system." Anyone familiar with Vue.js will instantly recognize the directives: x-data corresponds to data(), x-bind corresponds to v-bind, x-on corresponds to v-on. The decisive difference: Alpine.js lives entirely inside the HTML.
Including Alpine.js in the Hyvä Theme
Alpine.js is already fully integrated into the Hyvä Theme. It is loaded via Hyvä's own template system and is available on every page. You don't need to include Alpine.js manually; it runs automatically in the context of every .phtml template.
<!-- In a Hyvä layout: Alpine.js is already available globally -->
<!-- No require(['alpineJs'], ...) needed, no RequireJS! -->
<!-- Simple example directly in a phtml: -->
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Hello World</div>
</div>
That is the fundamental difference from the old Magento Luma theme: no RequireJS configuration, no data-mage-init attributes, no UI components with deeply nested Knockout bindings. Alpine.js code is readable, maintainable, and lives right where it takes effect.
2. The Core Directives
Alpine.js has 15 directives, used as HTML attributes. The seven most important ones are the foundation of every Alpine.js development effort in the Hyvä Theme.
x-data, the reactive state
x-data is the root of every Alpine.js component. It defines the reactive state as a JavaScript object. Every directive inside the element has access to this data. When a data value changes, Alpine.js updates the DOM automatically.
<!-- Inline definition: for simple cases -->
<div x-data="{ count: 0, name: 'Hyvä' }">
<p x-text="name"></p>
<button @click="count++">Clicks: <span x-text="count"></span></button>
</div>
<!-- Function reference: for complex components (recommended) -->
<div x-data="productGallery()">
<!-- ... -->
</div>
<script>
function productGallery() {
return {
activeImage: 0,
images: [],
init() {
// called automatically on initialization
}
}
}
</script>
x-show and x-if, controlling visibility
x-show and x-if both control whether an element is visible, but in fundamentally different ways. x-show toggles display: none via CSS, the element stays in the DOM. x-if removes the element from the DOM entirely and reinserts it.
<!-- x-show: element stays in the DOM, only CSS visibility changes -->
<!-- Good for: frequent toggles, elements with animations -->
<div x-data="{ menuOpen: false }">
<button @click="menuOpen = !menuOpen">Menu</button>
<nav x-show="menuOpen" x-transition>
<!-- Menu items -->
</nav>
</div>
<!-- x-if: element is removed from/added to the DOM -->
<!-- Good for: conditional content that is rarely shown -->
<template x-if="isLoggedIn">
<div class="customer-dashboard">
<!-- Only rendered when isLoggedIn === true -->
</div>
</template>
<!-- x-transition: smooth enter/leave animations with x-show -->
<div x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 translate-y-1">
Dropdown content
</div>
x-bind, setting attributes dynamically
x-bind binds HTML attributes to Alpine.js data values. The shorthand is the colon :. It lets you reactively control CSS classes, ARIA attributes, image sources, and any other HTML attribute.
<div x-data="{ isActive: false, imageSrc: '/media/product.jpg', rating: 4 }">
<!-- Simple attribute binding -->
<img :src="imageSrc" :alt="'Product image ' + rating">
<!-- Class binding: object notation -->
<button :class="{ 'bg-brand-red text-white': isActive, 'bg-slate-100': !isActive }"
@click="isActive = !isActive">
Toggle
</button>
<!-- Class binding: array notation -->
<div :class="['rounded-xl p-4', isActive ? 'shadow-lg' : 'shadow-sm']">
Content
</div>
<!-- ARIA attributes for accessibility -->
<button :aria-expanded="isActive"
:aria-label="isActive ? 'Close' : 'Open'"
@click="isActive = !isActive">
Toggle
</button>
</div>
x-on, capturing events
x-on registers event listeners directly in the HTML. The shorthand is the @ character. Alpine.js supports every native browser event as well as custom events via $dispatch.
<div x-data="{ message: '' }">
<!-- Click event (shorthand @click) -->
<button @click="message = 'Clicked!'">Click me</button>
<!-- Keyboard events with modifier -->
<input @keydown.enter="submitSearch()"
@keydown.escape="clearSearch()"
x-model="searchQuery">
<!-- Window events (e.g. for a scroll handler) -->
<div @scroll.window="handleScroll()"
@resize.window.debounce.300="updateLayout()">
</div>
<!-- Custom event (dispatched by Hyvä) -->
<div @product-added-to-cart.window="showCartNotification($event.detail)">
</div>
<!-- Modifiers: .prevent, .stop, .once, .self -->
<form @submit.prevent="handleSubmit()">
<button type="submit">Submit</button>
</form>
</div>
3. More Directives and Magic Properties
x-model, two-way data binding
x-model synchronizes the value of a form field with an Alpine.js variable in both directions. When the user changes the field, the variable updates, and vice versa.
<div x-data="{ qty: 1, selectedSize: '', search: '' }">
<!-- Number input with min/max value -->
<input type="number" x-model.number="qty" min="1" max="99">
<p>Quantity: <span x-text="qty"></span></p>
<!-- Select dropdown -->
<select x-model="selectedSize">
<option value="">Choose a size</option>
<option value="S">S</option>
<option value="M">M</option>
<option value="L">L</option>
</select>
<!-- Debounced search (waits 300ms after the last keystroke) -->
<input type="search" x-model.debounce.300ms="search"
@input="fetchSuggestions(search)">
</div>
x-for, rendering lists
x-for iterates over arrays and renders a DOM node for each entry. It only works with the <template> tag as a container.
<div x-data="{ products: [] }" x-init="products = await fetchProducts()">
<!-- Simple list -->
<template x-for="product in products" :key="product.id">
<div class="product-card">
<img :src="product.thumbnail" :alt="product.name">
<p x-text="product.name"></p>
<p x-text="'€ ' + product.price.toFixed(2)"></p>
</div>
</template>
<!-- With index -->
<template x-for="(item, index) in cartItems" :key="item.sku">
<div class="flex items-center gap-4">
<span x-text="index + 1" class="text-slate-400 text-sm"></span>
<span x-text="item.name"></span>
<span x-text="item.qty + ' x'" class="text-slate-500"></span>
</div>
</template>
</div>
x-text, x-html, and x-ref
x-text sets the text content of an element (automatically HTML-escaped), x-html sets raw HTML content (be careful with user-generated content!). x-ref creates a reference to a DOM element that can then be accessed via $refs.
<div x-data="{ title: '<b>Hyvä</b> Theme', price: 49.99 }">
<!-- x-text: HTML special characters are escaped -->
<p x-text="title"></p>
<!-- Output: "<b>Hyvä</b> Theme" (safe) -->
<!-- x-html: HTML is rendered, only for trusted sources! -->
<div x-html="title"></div>
<!-- Output: "Hyvä Theme" (bold) -->
<!-- x-ref: DOM reference for direct access -->
<input x-ref="priceInput" type="number" :value="price">
<button @click="$refs.priceInput.focus()">
Focus field
</button>
</div>
Magic properties: $el, $refs, $event, $dispatch, $nextTick, $watch
Inside directives, Alpine.js provides so-called magic properties, special variables that are automatically available.
// $el: The current DOM element
@click="$el.classList.toggle('active')"
// $refs: Access to elements marked with x-ref
@click="$refs.modal.showModal()"
// $event: The native browser event object
@click="handleClick($event.target.dataset.id)"
// $dispatch: Fire a custom event
@click="$dispatch('cart-updated', { itemCount: 3 })"
// $nextTick: Waits for the next DOM update cycle
@click="isOpen = true; $nextTick(() => $refs.input.focus())"
// $watch: Reacts to changes of a variable
x-init="$watch('qty', value => calculateTotal(value))"
// $store: Access to the global Alpine.js store
x-text="$store.cart.itemCount"
4. Alpine.js in Hyvä phtml Templates
In the Hyvä Theme, Alpine.js components are written directly inside .phtml templates. PHP supplies the data, Alpine.js takes care of the interactivity. This combination is the heart of the Hyvä development model.
Passing PHP data to Alpine.js
The most common use case: a PHP ViewModel supplies product data, Alpine.js renders it interactively. The data is passed via x-data using a PHP-generated JSON object.
<?php
/** @var \Magento\Catalog\Block\Product\View $block */
/** @var \Mironsoft\Catalog\ViewModel\ProductOptions $viewModel */
$viewModel = $block->getData('view_model');
$product = $block->getProduct();
// Prepare product data as JSON for Alpine.js
$productData = [
'id' => (int) $product->getId(),
'name' => $block->escapeHtml($product->getName()),
'price' => (float) $product->getFinalPrice(),
'images' => $viewModel->getGalleryImages($product),
'stock' => $viewModel->isInStock($product),
];
?>
<div x-data="productView(<?= $block->escapeHtmlAttr(json_encode($productData)) ?>)"
class="product-view-wrapper">
<!-- Product name (reactive) -->
<h1 x-text="product.name" class="text-2xl font-bold text-slate-900"></h1>
<!-- Price -->
<p class="text-3xl font-bold text-brand-red">
€ <span x-text="product.price.toFixed(2)"></span>
</p>
<!-- Image gallery -->
<div class="gallery-wrapper">
<img :src="product.images[activeImage].url"
:alt="product.images[activeImage].label"
class="w-full rounded-xl">
<div class="flex gap-2 mt-4">
<template x-for="(img, idx) in product.images" :key="idx">
<button @click="activeImage = idx"
:class="{ 'ring-2 ring-brand-red': activeImage === idx }"
class="w-16 h-16 rounded-lg overflow-hidden">
<img :src="img.thumbnail" :alt="img.label" class="w-full h-full object-cover">
</button>
</template>
</div>
</div>
<!-- Add to cart button -->
<button @click="addToCart()"
:disabled="!product.stock || loading"
:class="{ 'opacity-50 cursor-not-allowed': !product.stock || loading }"
class="btn-primary w-full mt-6">
<span x-show="!loading">Add to Cart</span>
<span x-show="loading">Adding...</span>
</button>
</div>
An important rule when passing PHP data to Alpine.js: always use $block->escapeHtmlAttr() for the x-data value, since it sits in an HTML attribute context. JSON encoding handles the correct escaping of the content.
5. The Component Pattern for the Hyvä Theme
For complex Alpine.js components, it is worth extracting the logic into a JavaScript function. This pattern keeps the HTML clean and makes it possible to reuse the same component in several places.
A component as a JavaScript function
// Pattern: Alpine.js component as a named function
// Placement: at the end of the .phtml template inside a <script> tag
function productView(initialData) {
return {
product: initialData,
activeImage: 0,
qty: 1,
loading: false,
// init() is called automatically on mount
init() {
// Listen for quantity changes
this.$watch('qty', qty => {
if (qty < 1) this.qty = 1;
if (qty > 99) this.qty = 99;
});
},
// Computed values (like Vue computed)
get formattedPrice() {
return '€ ' + this.product.price.toFixed(2);
},
get canAddToCart() {
return this.product.stock && !this.loading && this.qty > 0;
},
// Methods
setActiveImage(index) {
this.activeImage = Math.max(0, Math.min(index, this.product.images.length - 1));
},
async addToCart() {
if (!this.canAddToCart) return;
this.loading = true;
try {
const response = await fetch('/rest/V1/carts/mine/items', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + this.$store.customer.token,
},
body: JSON.stringify({
cartItem: {
sku: this.product.sku,
qty: this.qty,
quote_id: this.$store.cart.quoteId,
}
})
});
if (!response.ok) throw new Error('Cart error');
this.$dispatch('product-added-to-cart', { product: this.product, qty: this.qty });
} catch (e) {
this.$dispatch('cart-error', { message: e.message });
} finally {
this.loading = false;
}
}
};
}
Registering components globally with Alpine.data()
When a component is needed on several pages, it can be registered globally with Alpine.data(). In the Hyvä Theme this is typically done in a dedicated JavaScript file that is loaded via the layout.
// File: web/js/components/mini-cart.js
// Included via: layout/default.xml <script src="Vendor_Theme::js/components/mini-cart.js"/>
document.addEventListener('alpine:init', () => {
Alpine.data('miniCart', () => ({
open: false,
items: [],
total: 0,
itemCount: 0,
init() {
// On first load: read the cart from the store
this.syncWithStore();
// Event listener for cart updates
window.addEventListener('product-added-to-cart', () => this.syncWithStore());
},
syncWithStore() {
this.items = this.$store.cart.items;
this.total = this.$store.cart.total;
this.itemCount = this.$store.cart.itemCount;
},
async removeItem(itemId) {
await fetch(`/rest/V1/carts/mine/items/${itemId}`, { method: 'DELETE', ... });
this.$store.cart.removeItem(itemId);
this.syncWithStore();
}
}));
});
// Usage in the phtml template:
// <div x-data="miniCart()">
6. Global State with $store
With Alpine.store(), Alpine.js provides a global, reactive data store. Every component on the page can read from and write to the same store. For Magento 2 this is ideal for keeping cart status, customer data, and other page-wide state consistent.
// Store initialization, once when the page loads
document.addEventListener('alpine:init', () => {
// Cart store
Alpine.store('cart', {
quoteId: null,
items: [],
itemCount: 0,
total: 0,
isLoading: false,
async init() {
this.isLoading = true;
try {
const res = await fetch('/rest/V1/carts/mine', {
headers: { 'Authorization': 'Bearer ' + Alpine.store('customer').token }
});
const data = await res.json();
this.quoteId = data.id;
this.items = data.items || [];
this.itemCount = this.items.reduce((sum, i) => sum + i.qty, 0);
this.total = parseFloat(data.base_grand_total || 0);
} catch (e) {
console.error('Cart load failed', e);
} finally {
this.isLoading = false;
}
},
addItem(item) {
const existing = this.items.find(i => i.sku === item.sku);
if (existing) {
existing.qty += item.qty;
} else {
this.items.push(item);
}
this.itemCount = this.items.reduce((sum, i) => sum + i.qty, 0);
},
removeItem(itemId) {
this.items = this.items.filter(i => i.item_id !== itemId);
this.itemCount = this.items.reduce((sum, i) => sum + i.qty, 0);
}
});
// Customer store
Alpine.store('customer', {
isLoggedIn: false,
token: null,
name: '',
init() {
// Read the token from a cookie or PHP block
this.token = document.cookie.match(/token=([^;]+)/)?.[1] || null;
this.isLoggedIn = !!this.token;
}
});
});
// Usage in any component:
// <span x-text="$store.cart.itemCount"></span>
// <div x-show="$store.customer.isLoggedIn">Welcome!</div>
7. Alpine.js with the Magento 2 REST API
Alpine.js can talk to the Magento 2 REST API directly, no jQuery, no RequireJS needed. The native fetch API fully replaces the old $.ajax() pattern from Luma.
Loading a product preview on hover
function productQuickView(sku) {
return {
product: null,
loading: false,
error: null,
async loadProduct() {
if (this.product) return; // already loaded
this.loading = true;
this.error = null;
try {
// Magento REST API: product by SKU
const res = await fetch(`/rest/V1/products/${encodeURIComponent(sku)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
this.product = await res.json();
} catch (e) {
this.error = 'The product could not be loaded.';
} finally {
this.loading = false;
}
},
get thumbnailUrl() {
const img = this.product?.media_gallery_entries?.[0];
return img ? `/media/catalog/product${img.file}` : '/static/placeholder.jpg';
}
};
}
<!-- Usage in the phtml: -->
<div x-data="productQuickView('<?= $block->escapeJs($product->getSku()) ?>')"
@mouseenter="loadProduct()"
class="product-card cursor-pointer">
<!-- Skeleton loader while loading -->
<div x-show="loading" class="animate-pulse bg-slate-200 rounded-lg h-48"></div>
<!-- Product image after loading -->
<template x-if="product && !loading">
<div>
<img :src="thumbnailUrl" :alt="product.name" class="w-full rounded-lg">
<p x-text="product.name" class="font-semibold mt-2"></p>
</div>
</template>
<!-- Error message -->
<p x-show="error" x-text="error" class="text-red-500 text-sm"></p>
</div>
Search suggestions with debounce
function liveSearch() {
return {
query: '',
results: [],
loading: false,
open: false,
async search() {
if (this.query.length < 2) {
this.results = [];
this.open = false;
return;
}
this.loading = true;
try {
// Magento Catalog Search API
const params = new URLSearchParams({
'searchCriteria[filter_groups][0][filters][0][field]': 'name',
'searchCriteria[filter_groups][0][filters][0][value]': `%${this.query}%`,
'searchCriteria[filter_groups][0][filters][0][condition_type]': 'like',
'searchCriteria[pageSize]': '5',
'fields': 'items[id,sku,name,price],total_count',
});
const res = await fetch(`/rest/V1/products?${params}`);
const data = await res.json();
this.results = data.items || [];
this.open = this.results.length > 0;
} finally {
this.loading = false;
}
}
};
}
8. Hyvä Events and $dispatch
The Hyvä Theme relies on an event-based communication model between Alpine.js components. Instead of establishing direct references between components, custom browser events are fired via $dispatch and listened for on window.
Hyvä core events
The Hyvä Theme defines its own events, available throughout the theme. The most important ones:
// Hyvä standard events, always listen on window!
// Cart
'product-added-to-cart' // { product, qty }
'cart-item-removed' // { itemId }
'update-cart-item' // { itemId, qty }
// Mini cart
'toggle-minicart' // {}
'reload-cart-section' // {}
// Wishlist
'product-added-to-wishlist' // { productId }
'product-removed-from-wishlist' // { productId }
// Messages / notifications
'customer-data-reload' // { sectionNames: [...] }
'private-content-loaded' // { data }
// Gallery
'update-gallery' // { images: [...] }
'gallery-loaded' // {}
// Example: firing an event
this.$dispatch('product-added-to-cart', {
product: this.product,
qty: this.qty
});
// Example: receiving an event (on window!)
// <div @product-added-to-cart.window="showNotification($event.detail)">
Component communication via events
<!-- Scenario: product configuration communicates with the add-to-cart button -->
<!-- Component 1: size selector -->
<div x-data="{ selectedSize: null }"
class="size-selector">
<template x-for="size in ['XS','S','M','L','XL']">
<button @click="selectedSize = size;
$dispatch('size-selected', { size: size })"
:class="{ 'ring-2 ring-brand-red': selectedSize === size }"
class="size-btn"
x-text="size">
</button>
</template>
</div>
<!-- Component 2: add-to-cart button, receives size-selected -->
<div x-data="{ selectedSize: null, canAdd: false }"
@size-selected.window="selectedSize = $event.detail.size; canAdd = true"
class="add-to-cart-wrapper">
<button @click="addToCart(selectedSize)"
:disabled="!canAdd"
:class="{ 'opacity-50': !canAdd }"
class="btn-primary w-full">
<span x-show="!canAdd">Choose a size</span>
<span x-show="canAdd">Add to Cart</span>
</button>
</div>
9. Common Mistakes with Alpine.js in the Hyvä Theme
Mistake 1: Alpine is not defined
The most common mistake when switching over: ReferenceError: Alpine is not defined. The cause is a timing issue. Alpine.js registers itself asynchronously on load. Code that calls Alpine.store() or Alpine.data() must wait for the alpine:init event.
// WRONG: direct call, Alpine may not be ready yet
Alpine.store('cart', { ... }); // ReferenceError!
// RIGHT: inside the alpine:init event listener
document.addEventListener('alpine:init', () => {
Alpine.store('cart', { ... }); // Correct
Alpine.data('myComponent', () => ({ ... })); // Correct
});
Mistake 2: forgetting the window modifier
Hyvä events are fired on window. An event listener without the .window modifier only receives events fired on the element itself or its child nodes.
<!-- WRONG: does not receive events from other components -->
<div @product-added-to-cart="showNotification()">
<!-- RIGHT: .window modifier, receives all window events -->
<div @product-added-to-cart.window="showNotification($event.detail)">
Mistake 3: not escaping PHP data correctly
When passing PHP data to x-data, correct escaping is often missing. JSON special characters or quotes can break the Alpine.js parser.
<?php
// WRONG: no escaping, XSS risk and possible parse errors
<div x-data="{ name: '<?= $product->getName() ?>' }">
// RIGHT: json_encode + escapeHtmlAttr for the attribute context
$data = json_encode(['name' => $product->getName(), 'price' => $product->getPrice()]);
?>
<div x-data="<?= $block->escapeHtmlAttr($data) ?>">
// Or for more complex payloads: component function with a JSON parameter
<div x-data="productView(<?= $block->escapeHtmlAttr(json_encode($productData)) ?>)">
Mistake 4: x-if without a template tag
<!-- WRONG: x-if directly on a regular element -->
<div x-if="isLoggedIn">Welcome!</div>
<!-- RIGHT: x-if MUST sit on a <template> tag -->
<template x-if="isLoggedIn">
<div>Welcome!</div>
</template>
Mistake 5: breaking reactivity by mutating an array directly
// WRONG: direct index assignment, Alpine.js does not detect the change
this.items[0] = newItem; // Not reactive!
this.items.length = 0; // Not reactive!
// RIGHT: use array methods that are reactive
this.items.splice(0, 1, newItem); // Reactive
this.items = [...this.items]; // Reactive (new array)
this.items.push(newItem); // Reactive
this.items = this.items.filter(i => i.id !== deletedId); // Reactive
10. Alpine.js vs. Knockout.js, a Direct Comparison
Anyone migrating from Luma to Hyvä knows the culture shock: Knockout.js bindings, RequireJS modules, and data-mage-init attributes are replaced by Alpine.js. The concepts are similar, the implementation fundamentally different.
The upshot: Alpine.js is not just smaller and faster than Knockout.js, it is also considerably easier to learn and maintain. The code lives in the HTML, is instantly readable, and requires no mental context shift between HTML markup and JavaScript files.
Mironsoft
Hyvä Theme & Alpine.js Development
Want the Hyvä Theme built professionally?
We migrate your Magento 2 shop from Luma to the Hyvä Theme, build performant Alpine.js components, and train your development team in modern Magento frontend work.
Luma to Hyvä migration
Full theme migration, Knockout removal, performance optimization
Alpine.js components
Custom components, cart integration, product configurators
Core Web Vitals
LCP, CLS, INP optimization, Lighthouse scores above 95
11. Summary
Alpine.js is the JavaScript foundation of the Hyvä Theme and the most modern way to build interactive components in Magento 2. With declarative HTML markup, a reactive store system, and seamless integration with Hyvä events, it fully replaces Knockout.js, at a fraction of the complexity and size.
Alpine.js in the Hyvä Theme, the Essentials at a Glance
x-data & the component pattern
Simple state inline as an object, complex logic in named functions. Global reuse via Alpine.data() and the alpine:init event.
Passing PHP data
Always use json_encode() + $block->escapeHtmlAttr() for the x-data attribute context. No manual string concatenation.
Events & communication
Fire Hyvä events via $dispatch, receive them via @event.window. Always use the .window modifier for cross-component events.
$store for global state
Store cart, customer status, and other page-wide data in the Alpine.js store. Every component reads from the same store reactively.
12. FAQ: Alpine.js in the Hyvä Theme
1 What is Alpine.js and why is it used in the Hyvä Theme?
x-data, @click, and x-show directly in the markup.2 How does Alpine.js differ from Knockout.js?
data-bind attributes, UI components). Alpine.js has a flat learning curve, better performance, and noticeably more readable code. In the Hyvä Theme it is the only JS option; Luma Knockout code does not work there.3 How do I pass PHP data to Alpine.js safely?
json_encode() + $block->escapeHtmlAttr(): <div x-data="= $block->escapeHtmlAttr(json_encode($data)) ?>">. For component functions: x-data="productView(= $block->escapeHtmlAttr(json_encode($data)) ?>)". Never concatenate strings directly, that opens XSS holes and breaks the JSON parser.4 What is the difference between x-show and x-if?
x-show toggles display:none, the element stays in the DOM, good for frequent toggles with CSS transitions. x-if removes the element from the DOM entirely, must sit on <template>, good for rarely shown or expensive elements. x-show is the better choice for toggles in most cases.5 How do I register Alpine.js components globally?
Alpine.data() inside the alpine:init event: document.addEventListener('alpine:init', () => Alpine.data('name', () => ({...}))). The JS file is included via the layout. In the template then: <div x-data="name()">. This way the same component can be used on multiple pages.6 How do two Alpine.js components communicate with each other?
$dispatch('event-name', {data}) to send, @event-name.window="handler($event.detail)" to receive. The .window modifier is required for cross-component communication. Alternatively: a shared $store for shared state.7 How do I receive Hyvä's own events like 'product-added-to-cart'?
window: @product-added-to-cart.window="showNotification($event.detail)". Important: always add .window. Without this modifier, events from other components are not received. Custom events are fired with this.$dispatch('my-event', payload).8 What is the Alpine.js $store and when do I use it?
Alpine.store('name', {...}) inside the alpine:init event. Accessed in any component via $store.name.property. Changes are immediately reactive across all components, ideal as an alternative to events for frequently read state.9 How do I call the Magento 2 REST API in Alpine.js?
fetch(): const res = await fetch('/rest/V1/products/sku', { headers: { 'Authorization': 'Bearer ' + token } }). For POST requests: method: 'POST', body: JSON.stringify(data). jQuery and $.ajax() are not needed in the Hyvä Theme. With async/await the code stays clean and readable.10 Why does "ReferenceError: Alpine is not defined" appear?
Alpine.store() or Alpine.data() directly may run before Alpine.js has initialized. Fix: wrap everything in document.addEventListener('alpine:init', () => { ... }). This event is fired by Alpine.js shortly before it initializes the DOM, the reliable point for all registrations.