← Back to blog

Writing Alpine.js Components in Hyvä: Patterns That Scale

Rodrigo Biazzi

On a Hyvä store most of the frontend interactivity is written with Alpine.js, directly in PHP templates. For developers coming from Luma this feels strange at first. There is no RequireJS config, no widget factory, no Knockout bindings. You write a small function next to the markup and Alpine wires it up. This article covers how I structure Alpine components on Hyvä projects, how they get data from PHP, how they talk to each other and to Magento's customer data, and the mistakes that make them hard to maintain.

The basic shape of a Hyvä Alpine component

A component is a block of markup with an x-data attribute. On Hyvä the convention is to define the component as a named function in a script tag inside the same template, and to reference it from x-data. That keeps the logic readable and avoids long JavaScript expressions inside HTML attributes.

<?php
/** @var \Magento\Framework\View\Element\Template $block */
/** @var \Magento\Framework\Escaper $escaper */
$limit = 3;
?>
<div x-data="initStockNotice()" class="my-4">
    <p x-show="!open" class="text-sm">
        <?= $escaper->escapeHtml(__('Only a few left in stock.')) ?>
        <button type="button" class="underline" @click="open = true">
            <?= $escaper->escapeHtml(__('Details')) ?>
        </button>
    </p>
    <p x-show="open" x-cloak class="text-sm">
        <?= $escaper->escapeHtml(__('Orders with more than %1 units may ship separately.', $limit)) ?>
    </p>
</div>
<script>
    function initStockNotice() {
        return {
            open: false
        }
    }
</script>

A few points that matter here:

  • All output is escaped with $escaper, the same as any Magento template. Alpine does not change your XSS responsibilities.
  • x-cloak hides the element until Alpine initializes. Hyvä includes the CSS rule for it.
  • The function name is global. If the same template can render more than once on a page, the function is defined more than once, which is harmless, but two different templates must not use the same name for different components. Prefix names with something specific to the module.

Passing data from PHP to Alpine

Most real components need data from the server: a product ID, a configuration value, a price, a URL. There are two clean ways to pass it.

Arguments to the init function

For a few scalar values, pass them as arguments. Encode them as JSON so types and quoting are correct.

<?php
/** @var \Magento\Catalog\Block\Product\View $block */
/** @var \Magento\Framework\Escaper $escaper */
$product = $block->getProduct();
$config = [
    'productId' => (int) $product->getId(),
    'endpoint'  => $block->getUrl('acme_stock/ajax/check'),
];
?>
<div x-data="acmeStockCheck(<?= $escaper->escapeHtmlAttr(json_encode($config)) ?>)">
    <button type="button" @click="check()" :disabled="loading">
        <?= $escaper->escapeHtml(__('Check availability')) ?>
    </button>
    <p x-text="message"></p>
</div>
<script>
    function acmeStockCheck(config) {
        return {
            loading: false,
            message: '',
            check() {
                this.loading = true;
                const url = config.endpoint + '?product=' + encodeURIComponent(config.productId);
                fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
                    .then(response => response.json())
                    .then(data => { this.message = data.message; })
                    .catch(() => { this.message = ''; })
                    .finally(() => { this.loading = false; });
            }
        }
    }
</script>

The JSON goes into an attribute, so it is escaped with escapeHtmlAttr. Do not paste raw PHP values into JavaScript strings.

View models for anything with logic

If the data needs a repository call or configuration lookup, put it in a view model and keep the template thin. Hyvä templates have a $viewModels variable that gives you any view model class by name, so you do not need to declare each one as a block argument in layout XML.

<?php
/** @var \Hyva\Theme\Model\ViewModelRegistry $viewModels */
/** @var \Acme\Stock\ViewModel\StockNotice $stockNotice */
$stockNotice = $viewModels->require(\Acme\Stock\ViewModel\StockNotice::class);
?>

The view model is a normal class implementing Magento\Framework\View\Element\Block\ArgumentInterface, with dependencies injected through its constructor. This is the same pattern you would use on Luma. The difference is only how you get the instance in the template.

Customer data and private content

Full page cache means the HTML is the same for every visitor. The cart count, customer name and similar data come from customer data sections loaded by JavaScript after the page renders. On Luma, Knockout components subscribe to them through the Magento_Customer/js/customer-data module. On Hyvä there is no RequireJS, so the data arrives as a browser event.

When the sections are loaded, Hyvä dispatches a private-content-loaded event on window with the section data in event.detail.data. A component listens for it with Alpine's event syntax.

<div x-data="acmeCartBadge()"
     @private-content-loaded.window="receive($event.detail.data)">
    <span x-show="count > 0" x-text="count"></span>
</div>
<script>
    function acmeCartBadge() {
        return {
            count: 0,
            receive(data) {
                if (data.cart && data.cart.summary_count) {
                    this.count = data.cart.summary_count;
                }
            }
        }
    }
</script>

If your component changes something on the server that affects a section, for example adding to the cart through your own controller, ask Hyvä to reload the section data afterwards.

window.dispatchEvent(new CustomEvent('reload-customer-section-data'));

The sections themselves are defined the same way as on Luma, in etc/frontend/sections.xml and with a section source class. The server side does not change. For background on how sections and invalidation work, see Magento 2 customer data sections.

Form keys and POST requests

Magento controllers that accept POST check the form key. On Hyvä you get it from the global hyva object instead of reading a cookie through jQuery.

const body = new URLSearchParams();
body.append('form_key', hyva.getFormKey());
body.append('product', productId);

fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: body
});

For classic HTML forms rendered in the template you can keep using $block->getBlockHtml('formkey') to print the hidden input.

Communication between components

Components on a Hyvä page are independent. When one needs to tell another that something happened, use browser events. Dispatch a custom event from one component and listen for it with .window on the other.

<div x-data="{ qty: 1 }">
    <input type="number" min="1" x-model.number="qty"
           @change="$dispatch('acme-qty-changed', { qty: qty })">
</div>

<div x-data="{ qty: 1 }" @acme-qty-changed.window="qty = $event.detail.qty">
    <span x-text="qty"></span>
</div>

Prefix your event names. Generic names like update or change will eventually collide with something else on the page. For state that many components share, Alpine stores are an option, but on most Magento pages events are enough and easier to trace.

Content Security Policy

Magento 2.4.7 and later enforce stricter CSP rules on checkout pages, and stores with a strict policy cannot rely on inline scripts without nonces or hashes. Recent Hyvä versions provide a way to register inline scripts so they get the right nonce, and a CSP compatible way to write components that avoids evaluating expressions from attributes. If your store needs a strict CSP, follow the CSP section of the Hyvä documentation for your version from the beginning. Retrofitting it onto dozens of components later is much more work.

Keeping components maintainable

  • Keep markup and logic in the same template while the component is small. Move logic into a separate template included by the block when it grows past what fits on a screen.
  • Do not put business rules in JavaScript. Prices, stock rules and eligibility belong in PHP. Alpine should render state and react to the user.
  • Avoid long expressions in attributes. @click="open = !open" is fine. A ternary with three conditions belongs in a method.
  • Use x-show for things that toggle often and x-if inside a template tag for heavy markup that is rarely shown.
  • Defer expensive work until the user needs it. Use x-intersect if the Intersect plugin is loaded in your theme, or initialise on first interaction. Hyvä pages are fast because little JavaScript runs on load, so keep it that way.

Debugging

Most Alpine bugs on Hyvä are one of three things: a JavaScript error in the init function (check the console first), a function name collision between two templates, or data that was not escaped correctly and broke the attribute. View the rendered HTML source, not just the DOM inspector, to see exactly what PHP produced. The Alpine devtools browser extension also helps to inspect component state on a live page.

If a component works on a fresh page load but not after the cache is warm, the problem is almost always private data rendered in PHP. Move it to a customer data section and let the component read it from the event.

FAQ

Can I use jQuery inside a Hyvä Alpine component?

You can load it, but you should not. Hyvä's performance comes from not shipping jQuery, RequireJS and Knockout. Native fetch and DOM APIs cover what Magento storefront components need.

How do Hyvä components get the cart and customer data?

Hyvä dispatches a private-content-loaded event on window with the customer data sections. Components listen with @private-content-loaded.window and read event.detail.data.

Where should I put the JavaScript for a component?

In a script tag in the same template as the markup, as a named function referenced by x-data. Large components can be split into a separate template rendered by the same block.

Do I need a build step for Alpine components?

No. Alpine components are plain JavaScript in templates. Only the Tailwind CSS needs a build when you add new classes.

I am available for freelance Magento and Hyvä frontend work. If you need help with this, get in touch.

Related articles