ddtcorex

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-for keys. When looping, provide a :key so 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-data refresh. Fetch on interaction instead of on an interval.
  • Keep logic out of the template. For anything beyond trivial state, define an Alpine.data component; inline x-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

magento2hyvafrontendalpine