Accessible progressive UI

Coordinate Alpine and HTMX

Let HTMX own server communication and HTML replacement while Alpine owns local interaction state.

HTMX and Alpine solve different problems. HTMX talks to the server and swaps the HTML it gets back into the page. Alpine handles state that never leaves the browser. Together they’re two thirds of the AHA Stack.

They work well together as long as the ownership boundary is explicit. HTMX owns the network and the DOM it replaces. Alpine owns local interaction state. Neither reaches into the other’s job.

Split the board

  • HTMX: submitting the filter form, saving an issue, refreshing the list. Anything with a request.
  • Alpine: the filter panel open state, row disclosures, the editor’s typed title, character counts, focus.

The filter form becomes an HTMX request that replaces the list:

<form hx-get="/issues" hx-target="#issues" hx-trigger="change, submit"
  x-data="{ open: false }">
  <button type="button" @click="open = !open" :aria-expanded="open">Filters</button>
  <div x-show="open">
    <select name="status">
      <option value="">All</option>
      <option value="open">Open</option>
    </select>
  </div>
</form>

<ul id="issues">
  <li x-data="{ expanded: false }">...</li>
</ul>

Change the dropdown and HTMX fetches /issues?status=open and swaps the new <ul> in. Alpine’s open on the form is untouched, because the form wasn’t replaced. Only the list was.

New HTML initializes itself

The rows HTMX just inserted have x-data on them. Alpine watches the document for new elements and initializes them automatically. Each new <li> gets a fresh expanded: false.

That also means the old rows’ state is gone. A row you had expanded comes back collapsed. That’s the correct default: the server rendered a new list, and it doesn’t know about your disclosure.

The other direction needs a nudge

When Alpine inserts HTML, with x-if for example, HTMX doesn’t see it. A form with hx-post inside an x-if template submits as a plain GET, because HTMX never processed it.

I hit exactly this and wrote it up in using htmx inside an Alpine template tag. The fix is one call after the element appears:

<template x-if="editing">
  <form id="issue-editor" hx-put="/issues/42" hx-target="closest li"
    x-init="htmx.process($el)">

x-init runs when Alpine creates the form, and htmx.process() tells HTMX to read its attributes.

Don’t let both tools do the same job

The failure is two owners. Alpine’s save() calls fetch() and HTMX’s hx-put fires too, so the server gets two requests. Or Alpine mutates a list item that HTMX is about to replace, and the change vanishes.

Pick one per job. If HTMX saves the issue, delete the fetch from the Alpine component. Alpine keeps saving and error for display, and reads them from HTMX events if needed:

<form hx-put="/issues/42" x-data="{ saving: false }"
  @htmx:before-request="saving = true"
  @htmx:after-request="saving = false">

Listen to lifecycle events only where the two need to coordinate. Everywhere else, leave them alone.

Try the hard case on your board: open an editor on a row, then change the filter so HTMX replaces the list. Decide what should happen to the open editor, and make it happen on purpose, not by accident.

Lesson completed