Přeskočit na obsah
iD-SIGN
Ptejte se

Alpine.js v Symfony a spolupráce s htmx

Alpine.js nad HTML ze Symfony: strukturování kódu, Twig komponenty, soužití s htmx a morphem, modální okna s historií a formuláři a situace, kde Alpine doplní htmx.

díl 3/3 9 min čtenívydáno 11/2026

V tomto článku 9 kapitol
  1. Principy
  2. Start vedle htmx
  3. Strukturování kódu
  4. Twig komponenty
  5. Soužití s htmx
  6. Kde Alpine doplní htmx
  7. Modální okna
  8. Testování
  9. Kontrolní seznam

Třetí díl seriálu je o Alpine.js: jak ho v Symfony strukturovat, jak žije vedle htmx a kde doplní situace z prvního a druhého dílu.

Principy

Alpine.js drží stav v prohlížeči nad HTML, které vykreslil server. Nevykresluje stránku z dat a nepotřebuje API. Rozdělení s htmx je jednoduché:

  • Data a rozhodnutí patří serveru, do prohlížeče je přenáší htmx jako HTML.
  • Stav, o který server nestojí, drží Alpine: otevřené menu, zvolená záložka, maska pole, výpočet před odesláním, stav načítání.
  • Co umí prohlížeč sám, nechte jemu: <details> pro rozbalení, <dialog> pro modální okno. Alpine jen přepíná jejich stav.

Direktivy, magic properties a pluginy jen vyjmenujeme, popis je v dokumentaci Alpine.js:

  • direktivy: 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
  • oficiální pluginy: Mask, Intersect, Resize, Persist, Focus, Collapse, Anchor, Sort, Morph
  • rozšíření: Alpine.data(), Alpine.store(), Alpine.directive(), Alpine.magic()

Prvky skryté přes x-show dostanou x-cloak s pravidlem [x-cloak] { display: none !important; }, jinak před startem Alpine bliknou.

Start vedle htmx

javascript
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'

// Sem patří Alpine.data() a Alpine.store(), musí existovat dřív, než Alpine projde DOM.

window.Alpine = Alpine
Alpine.start()

window.Alpine = Alpine není jen pro ladění. hx-alpine-compat hledá Alpine právě na window, bez tohoto řádku by tiše nedělala nic.

Extension hx-alpine-compat je při morphu povinná. Bez ní morph přepíše DOM pod Alpine: x-text zůstane prázdné a rozepsaná hodnota v x-model se ztratí. S ní stav Alpine morph přežije.

Strukturování kódu

Stav má tři úrovně a přechod na vyšší má mít důvod:

  1. x-data v šabloně pro pár hodnot u jednoho prvku: x-data="{ open: false }".
  2. Alpine.data() v souboru, jakmile má komponenta metody nebo víc než dva řádky logiky. Testuje se a čte líp než kód v atributu.
  3. Alpine.store() jen pro stav, který je opravdu společný celé stránce. Typicky stačí jediný: které modální okno je otevřené.

Každou komponentu dejte do vlastního souboru, který ji zaregistruje, a ve vstupním souboru jen zavolejte registraci před Alpine.start():

javascript
// assets/js/menu.js
export default function registerMenu(Alpine) {
    Alpine.data('menu', () => ({
        open: false,
        section: null,

        toggle() {
            this.open = !this.open
        },
    }))
}
javascript
// assets/app.js
import registerMenu from './js/menu.js'

registerMenu(Alpine)

window.Alpine = Alpine
Alpine.start()
xml
<nav x-data="menu">
    <button type="button" x-on:click="toggle()" x-bind:aria-expanded="open">Menu</button>
</nav>

Data z Twigu

Hodnoty ze serveru nevkládejte do výrazu x-data, rozbije se na první uvozovce. Předejte je přes data-*, Twig je při autoescapingu bezpečně zakóduje:

xml
<div x-data="priceForm" data-plans="{{ plans|json_encode }}">
javascript
Alpine.data('priceForm', () => ({
    plans: [],

    init() {
        this.plans = JSON.parse(this.$el.dataset.plans)
    },
}))

Na co si dát pozor

  • $el v metodě není kořen komponenty. Metoda volaná z x-on:click na tlačítku dostane $el toho tlačítka a hledala by jen v něm. Kořen je this.$root, $el je kořenem jen v init().
  • Custom property v x-bind:style pište jako řetězec. U objektu si Alpine před změnou pamatuje předchozí hodnotu přes el.style['--x'], což je u custom properties vždy undefined. Po návratu by na prvku zůstalo --x: undefined.
  • Události pište x-on:click, ne @click. Důvod je u soužití s htmx níže.
  • $focus.next() z pluginu Focus hledá uvnitř prvku, na kterém je výraz. Na samotném tlačítku nic neudělá, patří na obal.

Twig komponenty

  • Rozhodování a výpočty patří do PHP třídy komponenty, šablona jen vykresluje. Alpine dostane hotové hodnoty přes data-*.
  • Vzhled nese ARIA, Alpine jen stav. Tlačítko má x-bind:aria-expanded="open" a styl se řídí atributem (v Tailwindu varianta aria-expanded:). Přístupnost a vzhled tak nemohou rozejít.
  • Výchozí stav vykreslí server. Filtr, záložka nebo rozbalení mají správnou podobu i bez JavaScriptu, Alpine ho jen přepíná. Odkaz zůstane odkazem s parametrem v URL.

Skrytý obsah volte podle toho, co je na něm drahé:

  • Drahý na získání na serveru (dotazy, výpočty, volání API) a zároveň nekritický pro SEO, například historie objednávek nebo graf vývoje ceny ve sbaleném oddílu: načtěte ho až při rozbalení přes htmx, viz lazy loading v dílu 1. x-if tu nepomůže, obsah uvnitř <template> server vykreslí při každém zobrazení stránky.
  • Drahý jen pro prohlížeč (velká inline SVG, tisíce prvků): vložte ho přes x-if, ne x-show. Prvky skryté přes x-show zůstávají v DOM a zatěžují každý přepočet stylů, i když je nikdo nevidí. Pozor na morph, viz níže.
  • Důležitý pro SEO: nechte ho v HTML, třeba ve sbaleném <details>. Co se načítá až při rozbalení, robot neuvidí.

Soužití s htmx

x-on: místo @

Morph kopíruje atributy z nové stránky přes setAttribute(). WebKit odmítne název @click, přestože ho parser HTML přijme. Morph pak skončí v půlce a na stránce zůstane směs nového a starého obsahu. Bez View Transitions se v konzoli objeví hláška o neplatném názvu, s nimi htmx chybu spolkne a nevidíte nic. V Chromu ani Firefoxu se chyba neprojeví.

Chyba se ukáže jen u přechodů, při kterých morph narazí na prvek s @ v novém obsahu. Typicky tedy na některých stránkách, zatímco jiné fungují, což svádí hledat příčinu v obsahu stránky. Řešením je psát všude x-on:. x-bind: a zkratka : problém nemají.

Události htmx v Alpine

xml
<nav x-data="menu" x-on:htmx:before:swap.window="open = false; section = null">

Události htmx mají tvar htmx:fáze:akce. Pomlčkový tvar htmx:before-swap neexistuje a listener na něm se tiše nikdy nespustí.

Co přežije morph

Stav Alpine přežije morph, a to je výhoda i past.

Menu, nabídky a jiné otevřené prvky zůstanou po přechodu otevřené. Zavřete je v htmx:before:swap a vynulujte i podřízený stav, jinak se po dalším otevření menu ukáže rovnou v podmenu. Třídu na <body> (zamčený posun pod menu) nastavujte přes $watch s document.body?.classList.

Obsah z x-if a x-for morph smaže. Server posílá jen <template>, morph podle něj vykreslené prvky odstraní a Alpine je nevrátí, dokud se stav nezmění. Typicky průvodce, ve kterém návštěvník volí svou situaci a vedle se přes x-if objeví ilustrace: po kliknutí na logo nebo přepnutí jazyka zůstane volba zvýrazněná a místo ilustrace prázdné místo, bez chyby v konzoli. Řešení jsou dvě:

  • hx-morph-skip-children na obalu šablony. Stav i obsah zůstanou, ale server obsah obalu už neobnoví.
  • Vrátit komponentu při swapu na začátek, jen když swap komponentu obsahuje:
javascript
resetOnSwap(event) {
    const isSwapped = event.detail.tasks?.some((task) => task.target?.contains(this.$root))
    if (!isSwapped) return
    this.selected = null
}
xml
<section x-data="guide" x-on:htmx:before:swap.window="resetOnSwap($event)">

Stejná vnitřní id pod různými kontejnery (viz díl 1) přenesou do nové stránky i staré uzly komponenty. Výsledkem je ReferenceError a prázdné x-text.

Cizí knihovny

Widget, který si DOM spravuje sám (mapa, editor), morph smaže, protože podle serveru v něm nic není. Na kořen widgetu dejte hx-morph-skip (zmrazí prvek i atributy), nebo hx-morph-skip-children (aktualizuje atributy kořene, potomky nechá).

Grafy je jednodušší zrušit a vytvořit znovu. Morph může <canvas> zachovat a nad novými daty by zůstala stará instance:

javascript
document.addEventListener('htmx:before:swap', destroyCharts)
htmx.onLoad(() => initCharts())

initCharts() zjišťuje existující instanci přes knihovnu, ne přes atribut, který by morph smazal.

hx-live

Extension hx-live přidává reaktivní výrazy do htmx, podobně jako Alpine. Když jsou na stránce obě, zkrácené :text patří Alpine a hx-live ho nevyhodnotí, jen vypíše warning. Pro hx-live vedle Alpine pište hx-live:text. Na jednom webu se ale držte jedné z nich.

Kde Alpine doplní htmx

Lazy loading cizí služby až na souhlas. Mapa od třetí strany se nemá načíst sama, protože by poslala data návštěvníka. Alpine ukáže překryv s informací, komu se data pošlou, a po kliknutí vyšle událost, na kterou skript mapu teprve stáhne:

xml
<div x-data="{ shown: false }">
    <div x-show="!shown">
        <p>Mapu poskytuje Google.</p>
        <button type="button" x-on:click="shown = true; $dispatch('map-requested')">Zobrazit mapu</button>
    </div>
    <div data-map hx-morph-skip></div>
</div>

Zpětná vazba ve formuláři před odesláním. Maska pole (x-mask="99/99/9999", $money), počet znaků, pole zobrazená podle volby (x-show podle x-model). Validace dál běží na serveru a vrací 422 (díl 2), Alpine jen šetří zbytečné odeslání.

Výpočet před odesláním. Cena podle zvolených možností se spočítá hned. Server ji při uložení spočítá znovu, prohlížeči nevěří.

Stažení souboru se skutečným průběhem. Místo odhadu času stáhne Alpine soubor přes fetch, tlačítko ukazuje stav, dokud odpověď nedorazí, a soubor uloží přes dočasný odkaz s download. Název vezme z Content-Disposition.

Filtr bez requestu. Pro malý seznam, který už na stránce je. Výchozí stav vykreslí server podle parametru v URL, chipy jsou odkazy a Alpine přepíná stav bez načtení stránky. Kanonická adresa je výpis bez parametru.

Událost ze serveru. Po přidání do košíku stačí, aby server poslal počet položek hlavičkou HX-Trigger, a Alpine ho přečte z události:

php
$response->headers->set('HX-Trigger', json_encode(['cart-updated' => ['count' => $cart->count()]]));
xml
<span x-data="{ count: {{ cart.count }} }"
      x-on:cart-updated.window="count = $event.detail.count"
      x-text="count">{{ cart.count }}</span>

Bez dat (HX-Trigger: cart-updated) událost přijde s prázdným detail. Když se mění HTML, je přímočařejší hx-swap-oob z dílu 2.

Modální okna

Uživatel čeká, že Zpět zavře otevřené okno. htmx by ale místo toho znovu stáhlo stránku. A protože stav Alpine přežije morph, okno se stavem ve vlastním x-data by zůstalo otevřené i na další stránce. Proto:

  • Které okno je otevřené, drží jeden store. Najednou je otevřené nejvýš jedno a s historií je provázané jediné místo.
  • Okno je nativní <dialog>. Modální vrstvu, Escape i focus trap dává prohlížeč, store se do něj jen promítá.
xml
<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>

Store i provázání s historií jsou v samostatném souboru, který se registruje stejně jako komponenty, tedy registerModal(Alpine) před Alpine.start():

javascript
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')

    // Zpět z položky okna jen zavře okno, stránka se znovu nestahuje.
    document.addEventListener('htmx:before:history:restore', (event) => {
        const isLeavingEntry = entryPath === event.detail.path
        entryPath = null
        modal.current = null
        if (isLeavingEntry) event.preventDefault()
    })

    // Vpřed okno znovu otevře.
    window.addEventListener('popstate', (event) => {
        if (!event.state?.modal) return
        entryPath = currentPath()
        modal.current = event.state.modal
    })

    // Odkaz z okna: nová stránka položku okna nahradí, jinak by Zpět vrátil jen adresu.
    document.addEventListener('htmx:before:history:update', (event) => {
        if (entryPath !== null) event.detail.history.type = 'replace'
        entryPath = null
        modal.current = null
    })
}
  • Fokus po zavření vraťte na prvek, který ho měl před otevřením, ale až po kroku v historii. Navigation API ho po kroku přesune na <body>. Počkejte na navigatesuccess, ve Firefoxu na popstate.
  • Modul pro scroll restoration z dílu 1 musí položky se stejnou adresou přeskočit, jinak by čekající posun skočil až při dalším swapu.

Formuláře v okně

  • Formulář vykreslete rovnou do stránky ({% include %} v dialogu), nenačítejte ho lazy. Odpadnou potíže s časováním, se zpracováním skrytých prvků a s $refs. Cenou je, že CSRF token na dlouho otevřené stránce může expirovat, proto pro ten případ nastavte srozumitelnou hlášku přes csrf_message a přeložte ji v doméně validators.
  • Odeslání swapuje jen obsah okna: hx-post s hx-target na obsah dialogu a hx-status z dílu 2 (422 swapnout, ostatní 4xx a 5xx ne).
  • Po úspěchu buď přejděte jinam přes HX-Location nebo HX-Redirect (přechod okno zavře), nebo okno zavřete událostí ze serveru a stránku aktualizujte přes hx-swap-oob:
php
$response->headers->set('HX-Trigger', 'modal-close');
xml
<body x-data x-on:modal-close.window="$store.modal.close()">

Testování

Chyby z tohoto dílu se projeví až po přechodu mezi stránkami a často jen v jednom prohlížeči, ruční proklikání je nenajde.

  • Přechody mezi všemi stránkami. Skript projde dvojice stránek, přejde z jedné na druhou přes htmx a porovná hlavní obsah s čerstvým načtením cílové stránky.
  • I ve WebKitu. Na Linuxu je nejlevnější WebKitWebDriver s MiniBrowser z balíčků distribuce a jednoduchý klient protokolu WebDriver.
  • S View Transitions i bez nich. S nimi se chyby polykají, bez nich se aspoň objeví v konzoli.
  • Stav po přechodu na stejnou stránku (logo, přepnutí jazyka): otevřené menu, vybraná volba, obsah z x-if.

Kontrolní seznam

  • hx-alpine-compat načtené, komponenty a store registrované před Alpine.start()
  • Události jen x-on:, žádné @
  • Logika v Alpine.data() nebo v PHP, data z Twigu přes data-*
  • Vzhled přes aria-*, výchozí stav vykreslený serverem, x-cloak u skrytých prvků
  • Listenery htmx ve tvaru htmx:fáze:akce
  • Menu a podřízený stav se při htmx:before:swap zavřou a vynulují
  • x-if a x-for chráněné hx-morph-skip-children, nebo komponenta při swapu začne znovu
  • Cizí widgety pod hx-morph-skip, grafy zrušené a vytvořené znovu
  • Modální okna jen přes jeden store a <dialog>, Zpět okno zavře
  • Formuláře v okně vykreslené do stránky, srozumitelná hláška při expirovaném CSRF
  • Přechody otestované ve WebKitu, s View Transitions i bez nich

Štítky Backend, PHP, JavaScript

Sdílet LinkedIn Facebook

Potřebujete víc než článek?

Stačí pár vět o tom, co řešíte.