Alpine + htmx

Combine Alpine.js for local state with htmx for server round-trips

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

Overview

Alpine.js handles state that lives entirely in the browser — a dropdown that's open or closed, a modal that's showing — without a server round-trip. htmx handles everything that needs the server: submitting a form, swapping in fresh HTML. This tutorial wires the two together so a dropdown or modal controls its own open/close while the form inside it posts to a route and swaps the result.

Reach for this combo when a UI element is interactive but its data still comes from the server. The two examples below are one pattern — Alpine owns the open/close, htmx owns the fetch — applied to a filter dropdown and a create-form modal.

Setup

Turn on Alpine injection in your config. Chirp injects the Alpine script before</body> on every full-page HTML response, so your templates can use x-data, x-show, and @clickanywhere:

from chirp import App, AppConfig

config = AppConfig(alpine=True)
app = App(config=config)

The htmx attributes below (hx-get, hx-post) assume htmx is on the page. Either keep your own htmx <script> in the layout, or set AppConfig(htmx=True)to have Chirp inject it the same way it injects Alpine. Both flags default to off — Chirp never injects a script you didn't ask for. For the fragment-swap model these attributes drive, see fragments and swaps.

Build it

Both examples below import a bundled macro, let Alpine own the open/close state, and let htmx fetch the HTML. Pick the variant you need.

A filter dropdown that submits via htmx when an option is selected:

{% from "chirp/alpine.html" import dropdown %}

{% call dropdown("Filter by status") %}
  <form hx-get="/tasks" hx-target="#task-list" hx-swap="innerHTML">
    <input type="hidden" name="status" value="open">
    <button type="submit" @click="open = false">Open</button>
  </form>
  <form hx-get="/tasks" hx-target="#task-list" hx-swap="innerHTML">
    <input type="hidden" name="status" value="closed">
    <button type="submit" @click="open = false">Closed</button>
  </form>
{% end %}

<div id="task-list">
  {% for task in tasks %}
    <div class="task">{{ task.title }}</div>
  {% endfor %}
</div>

The dropdown macro takes the trigger label as its first argument and ships its own open state. Alpine controls open/close; htmx runs the filter request and swaps #task-list.

A "New task" button opens a modal; the form posts via htmx and closes the modal when the request completes. Passmanaged=false so the parent's openstate controls the modal:

{% from "chirp/alpine.html" import modal %}

<div x-data="{ open: false }">
  <button type="button" @click="open = true">New Task</button>
  {% call modal("new-task-modal", title="New Task", managed=false) %}
    <form hx-post="/tasks" hx-target="#task-list" hx-swap="beforeend"
          @htmx:after-request="open = false">
      <input name="title" required>
      <button type="submit">Create</button>
    </form>
  {% end %}
</div>

<div id="task-list">
  {% for task in tasks %}
    <div class="task">{{ task.title }}</div>
  {% endfor %}
</div>

The parent x-data holds open. The button sets open = true; the form's @htmx:after-request sets open = false once the request finishes (it fires on any completed request, not only a 2xx). With managed=false the modal reads that parent openinstead of declaring its own.