HTMX and Alpine

Low JavaScript, high interactivity.

Overview

HTMX and Alpine are two lightweight JavaScript libraries that work well together to add interactivity to a page without needing to add a ton of custom javascript. They work through clever usage of HTML attributes.

HTMX is particularly suited for a Django environment like ours as it expects asynchronous responses to be HTML (instead of JSON), meaning we can return partial templates as responses. This allows us to utilize more of Django's backend power, rather than having to re-create a ton of logic in the front end.

Getting Started

If you want to quickly get started, you can use this js_entry point hqwebapp/js/htmx_and_alpine (no additional configuration necessary) and include hqwebapp/htmx/error_modal.html in the modals block of your page.

Django
{% extends "hqwebapp/bootstrap5/base_navigation.html" %}
{% load hq_shared_tags %}

{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  Do HTMX and Alpine things here!
{% endblock %}

{% block modals %}
  {% include "hqwebapp/htmx/error_modal.html" %}
  {{ block.super }}
{% endblock modals %}

Right now HTMX and Alpine are only available on pages using Webpack. If you don't know what this means, please read the Overview of Static Files and JavaScript Bundlers. If you don't know how to proceed after that, read further.

Important: HTMX and Alpine can only be included on pages using Webpack.

Usage with Forms

HTMX and Alpine are well suited for sprinkling bits of interactivity into an otherwise very static form. For instance, we want to hide or show a field depending on the value of a select input.

The in-page interactivity is handled by Alpine. HTMX is useful because it allows us to submit (and load) the form asynchronously.

Demo with Crispy Forms

Take a look at this simple demo of how we might use HTMX and Alpine with Crispy Forms. Tip: Make sure you are logged in.

Below we have two template views, HtmxAlpineFormDemoView and FilterDemoFormView. HtmxAlpineFormDemoView is the view that loads when visiting the URL above. It can be considered the "host" view, and asynchronously loads the partial template content from the FilterDemoFormView on page load. FilterDemoFormView then controls everything related to the FilterDemoForm.

Python
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView

from corehq.apps.domain.decorators import login_required
from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.hqwebapp.views import BasePageView
from corehq.apps.styleguide.examples.bootstrap5.htmx_alpine_form_demo import FilterDemoForm


@method_decorator(login_required, name='dispatch')
@method_decorator(use_bootstrap5, name='dispatch')
class HtmxAlpineFormDemoView(BasePageView):
    """
    This view is just a basic page view which acts as the "container" for a separate
    "FilterDemoFormView" that handles the interaction with the "FilterDemoForm".

    This establishes the page as a `js_entry` point for HTMX + Alpine and loads the
    form view below asynchronously in the page content.
    """
    urlname = "sg_htmx_alpine_form_demo"
    template_name = "styleguide/htmx_alpine_crispy/main.html"

    @property
    def page_url(self):
        return reverse(self.urlname)

    @property
    def page_context(self):
        return {
            "filter_form_url": reverse(FilterDemoFormView.urlname),
        }


# don't forget to add the same security decorators as the "host" view above!
@method_decorator(login_required, name='dispatch')
# the use_bootstrap5 decorator is needed here for crispy forms to work properly
@method_decorator(use_bootstrap5, name='dispatch')
class FilterDemoFormView(TemplateView):
    """
    This view inherits from a simple `TemplateView` because the `template_name` is a
    partial HTML template, so we don't need to extend any of the base HQ templates.
    """
    urlname = "sg_htmx_alpine_filter_form"
    template_name = "styleguide/htmx_alpine_crispy/partial_form.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        filter_form = kwargs.pop('filter_form') if 'filter_form' in kwargs else None
        context.update({
            "filter_form": filter_form or FilterDemoForm(),
        })
        return context

    def post(self, request, *args, **kwargs):
        filter_form = FilterDemoForm(request.POST)
        show_success = False
        if filter_form.is_valid():
            # do something with filter form data
            show_success = True
            filter_form = None
        return super().get(
            request,
            filter_form=filter_form,
            show_success=show_success,
            *args,
            **kwargs
        )

Take a look at the template for HtmxAlpineFormDemoView. You can see it uses a common js_entry entry point hqwebapp/js/htmx_and_alpine, which is appropriate for most common uses of HTMX and Alpine.

Django
{% extends "hqwebapp/bootstrap5/base_navigation.html" %}
{% load hq_shared_tags %}
{% load i18n %}

{# use the `hqwebapp/js/htmx_and_alpine` entry point to get started with HTMX and Alpine without any extra javascript #}
{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  <div
      class="container p-5"
      {# loads FilterDemoFormView on load with hx-get #}
      hx-trigger="load"
      hx-get="{{ filter_form_url }}"
  >
    <div class="htmx-indicator">
      <i class="fa-solid fa-spinner fa-spin"></i> {% trans "Loading..." %}
    </div>
  </div>
{% endblock %}

{% block modals %}
  {# you can either include this template or include an extension of this template to show HTMX errors to the user #}
  {% include "hqwebapp/htmx/error_modal.html" %}
  {{ block.super }}
{% endblock modals %}

The template for FilterDemoFormView then looks like this—a very simple form partial! The magic of what happens after the form is submitted is in the hx- attributes within the <form> tag that control the HTMX interactions.

Django
{% load i18n %}
{% load crispy_forms_tags %}

<div id="add-filter-form-container">
  {% if show_success %}
    <div
        class="alert alert-success alert-dismissible fade show"
        role="alert"
    >
      {% blocktrans %}
        Added filter!
      {% endblocktrans %}
      <button
          type="button"
          class="btn-close"
          data-bs-dismiss="alert"
          aria-label="{% trans "Close" %}"
      ></button>
    </div>
  {% endif %}

  <form
      hx-post="{{ request.path_info }}"
      hx-target="#add-filter-form-container"
      hx-swap="outerHTML"
      hx-disabled-elt="find button"
  >
    {% crispy filter_form %}
  </form>
</div>

The interaction within the form is then controlled by Alpine. Take a peek at self.helper.layout in the code below. All the x_ attributes define the Alpine model and interactions—no external JavaScript file necessary!

Python
import json

from crispy_forms import bootstrap as twbscrispy, layout as crispy
from crispy_forms.helper import FormHelper

from django import forms
from django.utils.translation import gettext_lazy, gettext as _


class MatchType:
    EXACT = "exact"
    IS_NOT = "is_not"
    CONTAINS = "contains"
    IS_EMPTY = "is_empty"

    OPTIONS = (
        (EXACT, gettext_lazy("is exactly")),
        (IS_NOT, gettext_lazy("is not")),
        (CONTAINS, gettext_lazy("contains")),
        (IS_EMPTY, gettext_lazy("is empty")),
    )

    MATCHES_WITH_VALUES = (
        EXACT, IS_NOT, CONTAINS,
    )


class FilterDemoForm(forms.Form):
    slug = forms.ChoiceField(
        label=gettext_lazy("Column"),
        choices=(
            ('name', gettext_lazy("Name")),
            ('color', gettext_lazy("Color")),
            ('desc', gettext_lazy("Description")),
        ),
        required=False
    )
    match = forms.ChoiceField(
        label=gettext_lazy("Match Type"),
        choices=MatchType.OPTIONS,
        required=False,
        help_text=gettext_lazy(
            "Hint: select 'is empty' to watch the Value field below disappear"
        )
    )
    value = forms.CharField(
        label=gettext_lazy("Value"),
        strip=False,
        required=False
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.helper = FormHelper()

        # We defer to defining the <form> tag in the template as we will
        # use HTMX to load and submit the form. Keeping the HTMX attributes
        # local to the template is preferred to maintain context.
        self.helper.form_tag = False

        # This form layout uses Alpine to toggle the visibility of
        # the "value" field:
        self.helper.layout = crispy.Layout(
            crispy.Div(
                'slug',
                crispy.Field(
                    'match',
                    # We initialize the match value in the alpine
                    # model defined below:
                    x_init="match = $el.value",
                    # and then two-way bind the alpine match
                    # model variable to this input:
                    x_model="match",
                ),
                crispy.Div(
                    'value',
                    # This uses alpine to determine whether to
                    # show the value field, based on the valueMatches
                    # list defined in the alpine model below:
                    x_show="valueMatches.includes(match)",
                ),
                twbscrispy.StrictButton(
                    _("Add Filter"),
                    type="submit",
                    css_class="btn btn-primary",
                ),
                # The Alpine data model is easily bound to the form
                # to control hiding/showing the value field:
                x_data=json.dumps({
                    "match": self.fields['match'].initial,
                    "valueMatches": MatchType.MATCHES_WITH_VALUES,
                }),
            ),
        )

    def clean(self):
        match = self.cleaned_data['match']
        value = self.cleaned_data['value']
        if match in MatchType.MATCHES_WITH_VALUES and not value:
            self.add_error('value', _("Please specify a value."))

Organization for Complex Pages

You can imagine that if a page needs to make a lot of different HTMX requests for multiple page interactions, the view list needed to process each request might get pretty long. Additionally, it might be challenging to properly identify these views as related to one main view.

That is where HqHtmxActionMixin comes in! You can use this mixin with any TemplateView (and common HQ subclasses, like BasePageView). Once mixed-in, any method on that view can be decorated with @hq_hx_action() to make that method name directly available as an "action" with any hx-get, hx-post, or related hx- request to that View's URL. This association can be made with the hq-hx-action attribute in the same element that has the hx- request tag.

Not quite following? Yes, it's a bit complex. Let's check out a demo and hopefully that will clear up any confusion...

Using HqHtmxActionMixin

To illustrate the usage of HqHtmxActionMixin, here is a simple To-Do List demo.

Let's first start with the TodoListDemoView, a subclass of BasePageView that mixes in HqHtmxActionMixin. You can see that the @hq_hx_action() decorator is applied to three methods in this view: create_new_item, edit_item, and mark_item_done.

Python
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _

from corehq.apps.domain.decorators import login_required
from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.hqwebapp.views import BasePageView
from corehq.apps.prototype.models.cache_store import CacheStore
from corehq.util.htmx_action import HqHtmxActionMixin, hq_hx_action


@method_decorator(login_required, name='dispatch')
@method_decorator(use_bootstrap5, name='dispatch')
class TodoListDemoView(HqHtmxActionMixin, BasePageView):
    """
    This view demonstrates how we use HqHtmxActionMixin with a view to provide
    HTMX responses when HTMX interacts with this view using the `hq-hx-action` attribute.
    """
    urlname = "sg_htmx_todo_list_example"
    template_name = 'styleguide/htmx_todo/main.html'

    @property
    def page_url(self):
        return reverse(self.urlname)

    @property
    def page_context(self):
        return {
            "items": self.get_items(),
        }

    def get_items(self):
        return TodoListStore(self.request).get()

    def save_items(self, items):
        TodoListStore(self.request).set(items)

    def update_item(self, item_id, name=None, is_done=None):
        items = self.get_items()
        for item in items:
            if item["id"] == item_id:
                item["name"] = name if name is not None else item["name"]
                item["is_done"] = is_done if is_done is not None else item["is_done"]
                TodoListStore(self.request).set(items)
                return item

    def render_item_response(self, request, item):
        template = ("styleguide/htmx_todo/item_done_oob_swap.html" if item["is_done"]
                    else "styleguide/htmx_todo/item.html")
        context = {
            'item': item,
        }
        return self.render_htmx_partial_response(request, template, context)

    # we can now reference `hq-hx-action="create_new_item"`
    # alongside a `hx-post` to this view URL
    @hq_hx_action('post')
    def create_new_item(self, request, *args, **kwargs):
        items = self.get_items()
        new_item = {
            "id": len(items) + 1,
            "name": _("New Item"),
            "is_done": False,
        }
        items.insert(0, new_item)
        self.save_items(items)
        return self.render_item_response(request, new_item)

    @hq_hx_action('post')
    def edit_item(self, request, *args, **kwargs):
        item = self.update_item(
            int(request.POST['itemId']),
            name=request.POST['newValue'],
        )
        return self.render_item_response(request, item)

    @hq_hx_action('post')
    def mark_item_done(self, request, *args, **kwargs):
        item = self.update_item(
            int(request.POST['itemId']),
            is_done=True,
        )
        return self.render_item_response(request, item)


class TodoListStore(CacheStore):
    """
    CacheStore is a helpful prototyping tool when you need to store
    data on the server side for prototyping HTMX views.

    Caution: Please don't use this for real features.
    """
    slug = 'styleguide-todo-list'
    initial_value = [
        {
            "id": 1,
            "name": "get coat hangers",
            "is_done": False,
        },
        {
            "id": 2,
            "name": "water plants",
            "is_done": False,
        },
        {
            "id": 3,
            "name": "Review PRs",
            "is_done": False,
        },
    ]

Looking at the main template for this view, we can scan the page for the first hx-post attribute, which posts to request.path_info. This is URL of the page serving this template: TodoListDemoView.

Below hx-post is the hq-hx-action attribute referencing the create_new_item method of the TodoListDemoView. Remember, this was one of the methods decorated with @hq_hx_action(). The rest of the attributes are specific to HTMX, and you can read more about them on htmx.org.

If you scan the rest of the template, you will notice another hq-hx-action attribute which references the method does_not_exist. As it is aptly named, this method does not exist in TodoListDemoView. However, that's the point, as this attribute is attached to a button which triggers the HTMX error modal. :)

Django
{% extends "hqwebapp/bootstrap5/base_navigation.html" %}
{% load hq_shared_tags %}
{% load i18n %}

{# use this entry point if you don't need to add any extra javascript to an HTMX + Alpine page #}
{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  <div class="container py-4">

    <h1>
      {% trans "To-Do" %}
      <button
          class="btn btn-primary btn-sm"
          {# request.path_info is just the same URL for this view #}
          {# an alternative would be {% url "sg_htmx_todo_list_example" %} #}
          hx-post="{{ request.path_info }}"
          {# hq-hx-action triggers the create_new_item method in TodoListDemoView #}
          hq-hx-action="create_new_item"
          {# the next two lines tell HTMX to insert the returned HTML from the post at the beginning of the #todo-list <ul> below #}
          hx-target="#todo-list"
          hx-swap="afterbegin"
      >
        <i class="fa-solid fa-plus"></i> {% trans "Add Item" %}
      </button>
    </h1>
    <ul
        class="list-group"
        id="todo-list"
    >
      {% for item in items %}
        {% if not item.is_done %}
          {% include 'styleguide/htmx_todo/item.html' %}
        {% endif %}
      {% endfor %}
    </ul>

    <h2 class="pt-5">
      {% trans "Done" %}
    </h2>
    <ul
        class="list-group"
        id="done-items"
    >
      {% for item in items %}
        {% if item.is_done %}
          {% include 'styleguide/htmx_todo/item_done.html' %}
        {% endif %}
      {% endfor %}
    </ul>

    <div class="py-5">
      <button
          class="btn btn-outline-danger"
          type="button"
          {# this button posts to a non existent @hq_hx_action method in the view, purposely triggering the error modal #}
          hx-post="{{ request.path_info }}"
          hq-hx-action="does_not_exist"
      >
        {% trans "Trigger Error" %}
      </button>
    </div>
  </div>
{% endblock %}

{% block modals %}
  {# you can either include this template or include an extension of this template to show HTMX errors to the user #}
  {% include "hqwebapp/htmx/error_modal.html" %}
  {{ block.super }}
{% endblock modals %}

What about the other two actions, edit_item and mark_item_done? To examine the usage for these, we need to look at the item.html template.

In this template, we see hq_hx_action="mark_item_done" applied to the checkbox input. Scrolling down, we see hq_hx_action="edit_item" applied to the form that contains the input with the edited form value.

However, how is the quick inline-edit interaction happening? That all comes from Alpine! Looking at the attributes in the li element on that template, we see the simple Alpine model being defined, setting up variables to track the isEditing and isSubmitting states. Additionally, the itemValue is defined and initialized from the template variable item.name. This itemValue is bound to the text input in the edit_item form below, with Alpine's x-model attribute.

The hide/show states of other elements are then controlled with Alpine's x-show attribute, while other form elements are disabled using Alpine's :disabled shorthand attribute when the isEditing or isSubmitting states change.

Django
{% load i18n %}
<li
    id="item-{{ item.id }}"
    class="list-group-item"
    {# the x-data below is from Alpine, and sets up the model for inline editing #}
    x-data='{
      isEditing: false,
      isSubmitting: false,
      itemValue: "{{ item.name }}",
    }'
>
  <div class="d-flex align-items-center fs-4 p-1">
    <input
        class="form-check-input me-3"
        type="checkbox"
        {# request.path_info is just the same URL for this view #}
        {# an alternative would be {% url "sg_htmx_todo_list_example" %} #}
        hx-post="{{ request.path_info }}"
        {# hq-hx-action triggers the mark_item_done method in TodoListDemoView #}
        hq-hx-action="mark_item_done"
        {# hx-vals is a handy way to send extra JSON-formatted data with the request #}
        hx-vals='{
          "itemId": {{ item.id }}
        }'
        {# the next two lines say replace the entire content of the target in hx-target #}
        hx-swap="outerHTML"
        hx-target="#item-{{ item.id }}"
        {# :disabled is shorthand for Alpine's x-bind:disabled #}
        :disabled="isEditing || isSubmitting"
    >

    {# the element below defines what the text looks like when not in edit mode #}
    <div
        class="pt-1"
        {# here we react to the value of isEditing from the alpine model set up in the parent <li> element #}
        x-show="!isEditing"
        {# @dblclick is shorthand for Alpine's x-on:dblclick #}
        @dblclick="isEditing = !isSubmitting"
    >
      <span>{{ item.name }}</span>
      <button
          class="btn btn-link btn-sm inline-edit-action"
          type="button"
          @click="isEditing = true"
          :disabled="isSubmitting"
      >
        <i class="fa fa-pencil"></i>
        <span class="visually-hidden">
          {% trans "Edit Value" %}
        </span>
      </button>
    </div>

    {# the element below defines what the inline edit looks like in edit mode #}
    <div x-show="isEditing">
      <form
          hx-post="{{ request.path_info }}"
          hq-hx-action="edit_item"
          hx-vals='{
            "itemId": {{ item.id }}
          }'
          {# hx-disabled-elt ensures that all buttons related to this form are disabled on post #}
          hx-disabled-elt="find button"
          hx-swap="outerHTML"
          hx-target="#item-{{ item.id }}"
      >
        <div class="input-group">
          <input
              type="text"
              class="form-control"
              name="newValue"
              {# x-model creates a two-way binding for the value of itemValue in the alpine model with the value of the input #}
              x-model="itemValue"
          >
          <button
              class="btn btn-primary"
              type="submit"
              @click="isSubmitting = true"
          >
            <i class="fa fa-check"></i>
          </button>
          <button
              class="btn btn-outline-danger"
              type="button"
              :disabled="isSubmitting"
              @click="isEditing = false"
          >
            <i class="fa fa-close"></i>
          </button>
        </div>
      </form>
    </div>
  </div>

</li>

From the code in TodoListDemoView, you can see that a request to the different @hq_hx_action() methods returns self.render_item_response. This returns render_htmx_partial_response, which is part of the HqHtmxActionMixin. Looking at this render_item_response method, we see two partial templates being returned.

The item.html template we've already examined above. However, when the item is marked as is_done, the method returns the item_done_oob_swap.html partial. Let's take a look at this template:

Django
{# hx-swap-oob inserts the result of this element in the #done-items <ul> instead of the #todo-list <ul> #}
<ul hx-swap-oob="afterbegin:#done-items">
  {% include 'styleguide/htmx_todo/item_done.html' %}
</ul>

As you can see, this template just adds a wrapper around the item_done.html template, but it has a very interesting HTMX attribute in this wrapper, hx-swap-oob. This was included to demonstrate a powerful feature of HTMX to do out-of-bounds swaps, which you can read about here.

Lastly, we have the item_done.html partial template. It's very simple:

Django
<li
    id="item-{{ item.id }}"
    class="list-group-item"
>
  <div class="p-2 fs-5">
    <i class="fa-solid fa-circle-check text-success"></i>
    <span class="ms-3">{{ item.name }}</span>
  </div>
</li>

That's it! Hopefully HqHtmxActionMixin seems less confusing now, and you are inspired to use it. :)

Debugging During Development

During development of a page using HTMX, you might be interested in seeing how the page looks like when requests take a long time to load, or when the server is a little flaky. Unfortunately, when you try to use the browser dev tools to simulate page slowness, you are only delaying sending the request to your local server, rather than simulating a slow server response. The distinction is important with HTMX, especially when debugging timeout issues.

To enable some advanced debugging features on a view using the HqHtmxActionMixin, you can also mix-in the HqHtmxDebugMixin. The HqHtmxDebugMixin allows you to simulate slow server responses by setting simulate_slow_response = True on your view. You can also simulate intermittent bad gateway errors with simulate_flaky_gateway = True.

You can check out the HqHtmxDebugMixin directly for additional documentation and usage guidance.

Loading Indicators

By default, if an element triggers an HTMX request, it will automatically get an htmx-request CSS class applied to it. We've added custom styling to _htmx.scss to support the common states outlined later in this section.

If you want to create new types of loading indicators for non-standard elements, you can explore using the hx-indicator attribute (see docs here).

It's often a great idea to pair button requests with hx-disabled-elt="this" (see docs for hx-disabled-elt), like the examples below, so that the requesting button is disabled during the request.

Buttons

Django
<button
  class="btn btn-primary" type="button"
  hx-get="{% url 'styleguide_a_hanging_view' %}"
  hx-disabled-elt="this"
  hx-swap="none"  {# example view returns nothing to display #}
>
  Click Me
</button>

Checkboxes

Django
<div class="form-check">
  <input
    id="id-flex-check-default"
    class="form-check-input"
    type="checkbox"
    value=""
    hx-get="{% url 'styleguide_a_hanging_view' %}"
    hx-disabled-elt="this"
    hx-swap="none"  {# example view returns nothing to display #}
  />
  <label for="id-flex-check-default" class="form-check-label">
    Try to check me
  </label>
</div>

Forms

In the example below, note that the form is the triggering element, so the hx-disabled-elt value is set to find button to disable all button children of that form during an HTMX request. Since form is the submitting element, by default it has the htmx-request class applied. Our styles ensure that button type="submit" elements of a submitting form show the loading indicator during an HTMX request.

Django
<form
  hx-get="{% url 'styleguide_a_hanging_view' %}"
  hx-disabled-elt="find button"
  hx-swap="none"  {# example view returns nothing to display #}
>
  <div class="mb-3">
    <label for="id-value" class="form-label">Value</label>
    <input id="id-value" class="textinput form-control" type="text" name="value" />
  </div>
  <button class="btn btn-primary" type="submit">
    Submit Me!
  </button>
</form>