The third part of the series is about Alpine.js: how to structure it in Symfony, how it lives alongside htmx and where it complements situations from the first and second part.
Principles
Alpine.js keeps state in the browser on top of HTML rendered by the server. It does not render the page from data and does not need an API. The split with htmx is simple:
- Data and decisions belong to the server, htmx carries them to the browser as HTML.
- State the server does not care about is kept by Alpine: an open menu, the selected tab, an input mask, a calculation before submission, a loading state.
- What the browser can do on its own, leave to it:
<details>for expanding,<dialog>for a modal window. Alpine only toggles their state.
We only list directives, magic properties and plugins, they are described in the Alpine.js documentation:
- directives:
x-data,x-init,x-show,x-if,x-for,x-bind,x-on,x-model,x-modelable,x-text,x-html,x-effect,x-ref,x-cloak,x-transition,x-teleport,x-id,x-ignore - magic properties:
$el,$root,$refs,$store,$watch,$dispatch,$nextTick,$id,$data - official plugins: Mask, Intersect, Resize, Persist, Focus, Collapse, Anchor, Sort, Morph
- extensions:
Alpine.data(),Alpine.store(),Alpine.directive(),Alpine.magic()
Elements hidden with x-show get x-cloak with the rule [x-cloak] { display: none !important; }, otherwise they flash before Alpine starts.
Starting alongside htmx
import htmx from 'htmx.org'
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-alpine-compat.js'
import 'htmx.org/dist/ext/hx-head.js'
import Alpine from 'alpinejs'
// Alpine.data() and Alpine.store() go here, they must exist before Alpine walks the DOM.
window.Alpine = Alpine
Alpine.start()
window.Alpine = Alpine is not just for debugging. hx-alpine-compat looks for Alpine exactly on window, without this line it would silently do nothing.
The hx-alpine-compat extension is required with morph. Without it, morph rewrites the DOM underneath Alpine: x-text ends up empty and a half-typed value in x-model is lost. With it, Alpine state survives morph.
Structuring code
State has three levels and moving to a higher one should have a reason:
x-datain the template for a few values on one element:x-data="{ open: false }".Alpine.data()in a file as soon as the component has methods or more than two lines of logic. It is easier to test and read than code in an attribute.Alpine.store()only for state that is truly shared by the whole page. Usually one is enough: which modal window is open.
Put each component in its own file that registers it, and in the entry file just call the registration before Alpine.start():
// assets/js/menu.js
export default function registerMenu(Alpine) {
Alpine.data('menu', () => ({
open: false,
section: null,
toggle() {
this.open = !this.open
},
}))
}
// assets/app.js
import registerMenu from './js/menu.js'
registerMenu(Alpine)
window.Alpine = Alpine
Alpine.start()
<nav x-data="menu">
<button type="button" x-on:click="toggle()" x-bind:aria-expanded="open">Menu</button>
</nav>
Data from Twig
Do not put values from the server into the x-data expression, it breaks on the first quote. Pass them via data-*, Twig encodes them safely with autoescaping:
<div x-data="priceForm" data-plans="{{ plans|json_encode }}">
Alpine.data('priceForm', () => ({
plans: [],
init() {
this.plans = JSON.parse(this.$el.dataset.plans)
},
}))
Watch out for
$elin a method is not the component root. A method called fromx-on:clickon a button gets$elof that button and would search only inside it. The root isthis.$root;$elis the root only ininit().- Write a custom property in
x-bind:styleas a string. With an object, Alpine remembers the previous value before a change viael.style['--x'], which is alwaysundefinedfor custom properties. After going back,--x: undefinedwould remain on the element. - Write events as
x-on:click, not@click. The reason is in the section on living alongside htmx below. $focus.next()from the Focus plugin searches inside the element the expression is on. On the button itself it does nothing, it belongs on the wrapper.
Twig components
- Decisions and calculations belong in the component's PHP class, the template only renders. Alpine gets finished values via
data-*. - ARIA carries the look, Alpine only the state. The button has
x-bind:aria-expanded="open"and the style follows the attribute (in Tailwind thearia-expanded:variant). Accessibility and appearance thus cannot drift apart. - The server renders the default state. A filter, tab or expandable section looks right even without JavaScript, Alpine only toggles it. A link stays a link with a parameter in the URL.
Choose how to handle hidden content based on what makes it expensive:
- Expensive to obtain on the server (queries, calculations, API calls) and at the same time not critical for SEO, for example an order history or a price history chart in a collapsed section: load it only on expanding via htmx, see lazy loading in part 1.
x-ifdoes not help here, the server renders the content inside<template>on every page view. - Expensive only for the browser (large inline SVGs, thousands of elements): insert it with
x-if, notx-show. Elements hidden withx-showstay in the DOM and weigh on every style recalculation, even when nobody sees them. Beware of morph, see below. - Important for SEO: keep it in the HTML, for example in a collapsed
<details>. What is loaded only on expanding, a crawler will not see.
Living alongside htmx
x-on: instead of @
Morph copies attributes from the new page via setAttribute(). WebKit rejects the name @click, even though the HTML parser accepts it. Morph then stops halfway and the page is left with a mix of new and old content. Without View Transitions, a message about an invalid name appears in the console; with them, htmx swallows the error and you see nothing. In Chrome and Firefox the error does not show up.
The error appears only on transitions where morph hits an element with @ in the new content. Typically on some pages while others work, which tempts you to look for the cause in the page content. The solution is to write x-on: everywhere. x-bind: and the : shorthand do not have this problem.
htmx events in Alpine
<nav x-data="menu" x-on:htmx:before:swap.window="open = false; section = null">
htmx events have the form htmx:phase:action. The hyphenated form htmx:before-swap does not exist and a listener on it silently never fires.
What survives morph
Alpine state survives morph, which is both an advantage and a trap.
Menus, dropdowns and other open elements stay open after the transition. Close them in htmx:before:swap and reset the nested state too, otherwise the menu shows up right in the submenu the next time it opens. Set the class on <body> (scroll locked under the menu) via $watch with document.body?.classList.
Content from x-if and x-for is removed by morph. The server sends only <template>, morph removes the rendered elements accordingly and Alpine does not bring them back until the state changes. A typical case is a guide where the visitor chooses their situation and an illustration appears next to it via x-if: after clicking the logo or switching languages, the choice stays highlighted and there is an empty space instead of the illustration, without an error in the console. There are two solutions:
hx-morph-skip-childrenon the template's wrapper. State and content stay, but the server no longer updates the wrapper's content.- Reset the component on swap, but only when the swap contains the component:
resetOnSwap(event) {
const isSwapped = event.detail.tasks?.some((task) => task.target?.contains(this.$root))
if (!isSwapped) return
this.selected = null
}
<section x-data="guide" x-on:htmx:before:swap.window="resetOnSwap($event)">
The same inner id under different containers (see part 1) carries the component's old nodes into the new page as well. The result is a ReferenceError and an empty x-text.
Third-party libraries
A widget that manages its own DOM (a map, an editor) is wiped by morph, because according to the server there is nothing in it. Put hx-morph-skip on the widget's root (freezes the element and its attributes), or hx-morph-skip-children (updates the root's attributes, leaves the children alone).
Charts are easier to destroy and create again. Morph may keep the <canvas> and the old instance would stay on top of new data:
document.addEventListener('htmx:before:swap', destroyCharts)
htmx.onLoad(() => initCharts())
initCharts() detects an existing instance through the library, not through an attribute that morph would remove.
hx-live
The hx-live extension adds reactive expressions to htmx, similar to Alpine. When both are on the page, the :text shorthand belongs to Alpine and hx-live does not evaluate it, it only logs a warning. For hx-live next to Alpine, write hx-live:text. On a single website, though, stick to one of them.
Where Alpine complements htmx
Lazy loading a third-party service only after consent. A map from a third party should not load on its own, because it would send the visitor's data. Alpine shows an overlay saying who the data will be sent to, and after a click dispatches an event on which a script only then downloads the map:
<div x-data="{ shown: false }">
<div x-show="!shown">
<p>The map is provided by Google.</p>
<button type="button" x-on:click="shown = true; $dispatch('map-requested')">Show map</button>
</div>
<div data-map hx-morph-skip></div>
</div>
Feedback in a form before submission. An input mask (x-mask="99/99/9999", $money), a character count, fields shown based on a choice (x-show based on x-model). Validation still runs on the server and returns 422 (part 2), Alpine only saves unnecessary submissions.
Calculation before submission. The price based on the selected options is calculated immediately. The server calculates it again when saving, it does not trust the browser.
File download with real progress. Instead of guessing the time, Alpine downloads the file via fetch, the button shows the state until the response arrives, and the file is saved through a temporary link with download. The name is taken from Content-Disposition.
Filter without a request. For a small list that is already on the page. The server renders the default state based on a URL parameter, the chips are links and Alpine toggles the state without loading the page. The canonical URL is the listing without the parameter.
An event from the server. After adding to the cart, it is enough for the server to send the item count in the HX-Trigger header, and Alpine reads it from the event:
$response->headers->set('HX-Trigger', json_encode(['cart-updated' => ['count' => $cart->count()]]));
<span x-data="{ count: {{ cart.count }} }"
x-on:cart-updated.window="count = $event.detail.count"
x-text="count">{{ cart.count }}</span>
Without data (HX-Trigger: cart-updated) the event arrives with an empty detail. When HTML changes, hx-swap-oob from part 2 is more straightforward.
Modal windows
Users expect Back to close an open window. htmx, however, would download the page again instead. And because Alpine state survives morph, a window with its state in its own x-data would stay open on the next page too. Therefore:
- One store keeps which window is open. At most one is open at a time and only one place is tied to history.
- The window is a native
<dialog>. The browser provides the modal layer, Escape and the focus trap; the store is only projected into it.
<button type="button" x-on:click="$store.modal.open('client')">Detail</button>
<dialog x-effect="$store.modal.isOpen('client') ? ($el.open || $el.showModal()) : $el.close()"
x-on:close="$store.modal.close()">…</dialog>
The store and the history wiring live in a separate file, registered the same way as components, i.e. registerModal(Alpine) before Alpine.start():
export default function registerModal(Alpine) {
let entryPath = null
const currentPath = () => location.pathname + location.search
Alpine.store('modal', {
current: null,
open(name) {
if (entryPath === null) {
history.pushState({ modal: name }, '')
entryPath = currentPath()
} else {
history.replaceState({ modal: name }, '')
}
this.current = name
},
close() {
if (this.current === null) return
this.current = null
if (entryPath !== null) history.back()
},
isOpen(name) {
return this.current === name
},
})
const modal = Alpine.store('modal')
// Back from the window's entry only closes the window, the page is not downloaded again.
document.addEventListener('htmx:before:history:restore', (event) => {
const isLeavingEntry = entryPath === event.detail.path
entryPath = null
modal.current = null
if (isLeavingEntry) event.preventDefault()
})
// Forward opens the window again.
window.addEventListener('popstate', (event) => {
if (!event.state?.modal) return
entryPath = currentPath()
modal.current = event.state.modal
})
// A link from the window: the new page replaces the window's entry, otherwise Back would restore only the URL.
document.addEventListener('htmx:before:history:update', (event) => {
if (entryPath !== null) event.detail.history.type = 'replace'
entryPath = null
modal.current = null
})
}
- Return focus after closing to the element that had it before opening, but only after the history step. The Navigation API moves it to
<body>after the step. Wait fornavigatesuccess, in Firefox forpopstate. - The scroll restoration module from part 1 must skip entries with the same URL, otherwise a pending scroll would jump only at the next swap.
Forms in a window
- Render the form directly into the page (
{% include %}in the dialog), do not lazy load it. This avoids timing issues, problems with processing hidden elements and with$refs. The price is that the CSRF token may expire on a page left open for a long time, so set a clear message for that case viacsrf_messageand translate it in thevalidatorsdomain. - Submission swaps only the window's content:
hx-postwithhx-targetpointing to the dialog's content andhx-statusfrom part 2 (swap 422, not the other4xxand5xx). - After success, either move elsewhere via
HX-LocationorHX-Redirect(the transition closes the window), or close the window with an event from the server and update the page viahx-swap-oob:
$response->headers->set('HX-Trigger', 'modal-close');
<body x-data x-on:modal-close.window="$store.modal.close()">
Testing
The errors in this part show up only after a transition between pages and often only in one browser; clicking through manually will not find them.
- Transitions between all pages. A script goes through pairs of pages, moves from one to the other via htmx and compares the main content with a fresh load of the target page.
- In WebKit too. On Linux the cheapest option is
WebKitWebDriverwithMiniBrowserfrom the distribution packages and a simple WebDriver protocol client. - With and without View Transitions. With them errors are swallowed, without them they at least appear in the console.
- State after a transition to the same page (logo, language switch): open menu, selected choice, content from
x-if.
Checklist
hx-alpine-compatloaded, components and store registered beforeAlpine.start()- Events only as
x-on:, no@ - Logic in
Alpine.data()or in PHP, data from Twig viadata-* - Look via
aria-*, default state rendered by the server,x-cloakon hidden elements - htmx listeners in the form
htmx:phase:action - Menu and nested state close and reset on
htmx:before:swap x-ifandx-forprotected byhx-morph-skip-children, or the component starts over on swap- Third-party widgets under
hx-morph-skip, charts destroyed and created again - Modal windows only through one store and
<dialog>, Back closes the window - Forms in a window rendered into the page, a clear message for an expired CSRF token
- Transitions tested in WebKit, with and without View Transitions