HTMX and Alpine
Low JavaScript, high interactivity.
On this page
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.
{% 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.
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
.
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.
{% 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.
{% 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!
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
.
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. :)
{% 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.
{% 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:
{# 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:
<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
<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
<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.
<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>