02 Sept 2026 · 3 min read
Alpine.js patterns that replace Knockout in Hyva
How to express the interactive widgets Luma built with Knockout and RequireJS using Alpine.js in Hyva — x-data components, events, and fetching without a global binding engine.
Part 7 of the Hyva series — building the core storefront. Implementation-focused.
From bindings to attributes
Knockout expressed interactivity as data-bind attributes backed by a uiElement view model and a binding engine that watched the DOM. Alpine expresses the same behavior with declarative x-* attributes and a component scope created by x-data. There is no global registry and no polling loop — each widget initializes when its element mounts and cleans up when it leaves.
The simplest component holds local state:
<div x-data="{ qty: 1 }" class="flex items-center gap-2">
<button x-on:click="qty = Math.max(1, qty - 1)">−</button>
<span x-text="qty"></span>
<button x-on:click="qty++">+</button>
</div>
State, markup, and behavior live together. A developer reading the template sees exactly what happens on click without opening a separate view-model file.
Extracting reusable components
When a widget recurs, move its logic into a small Alpine component registered on the global Alpine.data:
document.addEventListener('alpine:init', () => {
Alpine.data('addToCart', () => ({
qty: 1,
loading: false,
async submit() {
this.loading = true;
await fetch('/example-store/cart/add', {
method: 'POST',
body: new URLSearchParams({ qty: this.qty }),
});
this.loading = false;
},
}));
});
Then the template references it by name:
<form x-data="addToCart" @submit.prevent="submit">
<button x-bind:disabled="loading" x-text="loading ? 'Adding…' : 'Add to cart'"></button>
</form>
This is the Hyva equivalent of a Knockout component: shared behavior, no RequireJS module, no binding engine.
Events without coupling
Knockout wired widgets together through observables and the customer-data section loader. Alpine uses the DOM event model. A parent can listen to a child through x-on, and unrelated widgets communicate through dispatched events:
<button @click="$dispatch('cart-updated', { count: 3 })">Update</button>
<div @cart-updated.window="count = $event.detail.count">…</div>
The .window modifier listens at the window level, so widgets do not need a shared parent. This replaces the global pub/sub that Luma relied on, with the browser's own event system.
Fetching data on demand
Luma often pre-rendered data into JSON literals embedded in PHTML so Knockout could bind it. Hyva templates can do the same, but for truly on-demand data, fetch from a controller and let Alpine manage the state:
Alpine.data('priceTier', () => ({
tiers: [],
async init() {
this.tiers = await (await fetch('/example-store/price-tiers')).json();
},
}));
Because the request happens in the browser after render, the initial HTML stays small — exactly the payload reduction that makes Hyva fast.
Pitfalls
- Reactivity needs
x-data. A plain<div x-text="count">does nothing; the scope must be declared. x-forkeys. When looping, provide a:keyso Alpine tracks items correctly, the same way you would in any framework.- Don't reintroduce polling. The whole point is to drop Luma's per-page
customer-datarefresh. Fetch on interaction instead of on an interval. - Keep logic out of the template. For anything beyond trivial state, define an
Alpine.datacomponent; inlinex-data="{ ... }"objects get unreadable fast.
What's next
Alpine handles behavior, but display logic that touches the catalog, stock, or pricing belongs in PHP. The next post covers Hyva view models — the PHP classes that keep templates declarative and testable.
Next in this series: Hyva view models — moving logic to PHP —
/blog/magento2-hyva-view-models