django-hx-forms
Django forms mixin for managing form updates using htmx triggers
django-hx-forms provides a mixin for Django forms to allow related form fields to be updated via HTMX events. No javascript is required (aside from HTMX itself). All the logic surrounding related fields is handled with the Django form. Examples:
- A choice field needs updating based on another choice field being selected
- A field needs hiding based on another field's state
- A field needs resetting based on another field's state
- A field needs disabling based on another field's state
The form allows rules to be defined to identify which fields should trigger an update, and which fields are affected by this trigger.
Installation
With uv:
uv add django-hx-forms
With pip:
pip install django-hx-forms
Or add it to your pyproject.toml dependencies directly:
dependencies = [
"django-hx-forms",
]
You also need HTMX loaded in your templates — see the HTMX installation instructions for the current options.
Usage
A reactive form is made of three parts: a form that declares its trigger fields and how it reacts, a view that re-renders the form when a trigger fires, and a template partial shared between the initial page and the HTMX response.
The examples below use a country/region form, where choosing a country narrows the available regions.
Form mixin
Subclass HtmxModelForm (a ModelForm with the mixin applied) or add HtmxFormMixin to a plain Form.
Point the hx_* attributes at your update endpoint, declare the reactive rules in Meta, and override
check_form_state() to adjust the form for the current values.
from django.urls import reverse_lazy
from django_hx_forms.forms import HtmxModelForm
from .models import Address
class AddressForm(HtmxModelForm):
hx_post = reverse_lazy("addresses:form-update") # endpoint HTMX posts to
hx_target = "#address-fields" # element swapped with the response
hx_indicator = "#form-loading" # optional loading indicator
default_focus_field = "country" # field autofocused on first render
def check_form_state(self):
"""Adjust field state for the current values. Runs on every render."""
country = self.get_field_value("country")
if country:
self.enable_field("region")
self.set_field_required("region", True)
self.set_field_queryset("region", Region.objects.filter(country=country))
else:
self.disable_field("region")
self.set_field_required("region", False)
class Meta:
model = Address
fields = ["country", "region"]
# Changing `country` fires an HTMX request to re-render the form…
htmx_trigger_fields = ["country"]
# …and clears `region` so a stale value can't survive the change
htmx_field_resets = {"country": ["region"]}
hx_post/hx_target/hx_indicator— the mixin attaches the matchinghx-post,hx-targetandhx-indicatorattributes to each field inMeta.htmx_trigger_fields; you don't write them in the template.htmx_trigger_fields— fields whose change re-renders the form.htmx_field_resets— a{trigger_field: [fields_to_clear]}map applied before re-rendering.check_form_state()— enable/disable fields, swap choices or querysets, and toggle required state here. The view calls it on each update; call it from__init__too if you want it applied on the first render.
View mixin
HtmxFormUpdateViewMixin serves the re-rendered partial. It reads the trigger from the HX-Trigger-Name
header, applies the resets, calls check_form_state(), and rejects non-HTMX requests with a 403.
from django.shortcuts import get_object_or_404
from django_hx_forms.views import HtmxFormUpdateViewMixin
from .forms import AddressForm
from .models import Address
class AddressFormUpdateView(HtmxFormUpdateViewMixin):
form_class = AddressForm
template_name = "addresses/_address_fields.html"
def get_form_instance(self):
"""Return the instance being edited (ModelForms only), or None when adding."""
address_id = self.request.POST.get("address_id")
return get_object_or_404(Address, pk=address_id) if address_id else None
Route it at the URL named in hx_post:
path("form-update/", AddressFormUpdateView.as_view(), name="form-update"),
Your ordinary CreateView / UpdateView reuse the same form and don't need this mixin — they render the
full page, and the update view only handles the reactive refresh.
Templates
Keep the reactive fields in a partial so the initial page and the HTMX response render identically:
{# addresses/_address_fields.html #}
{{ form.country.label_tag }} {{ form.country }}
{{ form.region.label_tag }} {{ form.region }}
The full page wraps that partial in the hx_target element (plus the optional hx_indicator):
<form method="post">
{% csrf_token %}
{% if object %}<input type="hidden" name="address_id" value="{{ object.pk }}">{% endif %}
<div id="form-loading" class="htmx-indicator">Updating…</div>
<div id="address-fields">
{% include "addresses/_address_fields.html" %}
</div>
<button type="submit">Save</button>
</form>
Changing the country field posts to the update view and swaps in a refreshed #address-fields fragment
with region enabled and re-populated — no page reload and no custom JavaScript. When you render fields by
hand rather than with {{ form.region }}, honour the widget attributes the mixin sets (disabled,
autofocus, required).
What's included
Forms (django_hx_forms.forms)
- HtmxFormMixin — trigger/reset handling plus field-state helpers:
get_field_value(),enable_field()/disable_field()(and*_fieldsplurals),set_field_required(),set_field_queryset(),set_field_initial(),remove_field(),add_htmx_field_error(). - HtmxModelForm —
HtmxFormMixincombined with Django'sModelForm.
Views (django_hx_forms.views)
- HtmxFormUpdateViewMixin — renders the form partial on HTMX trigger requests; override
get_form_instance()for ModelForms. - IsHtmxRequestMixin — returns 403 for non-HTMX requests (used by
HtmxFormUpdateViewMixin).
Running the demo
A complete working example lives in demo/ — a shopping cart form where choosing a product type
reveals different fields, updates colour choices, and resets fields that no longer apply. It's a standalone
project that installs this package from the parent directory:
cd demo
uv sync
uv run python manage.py migrate
uv run python manage.py runserver
Then open http://127.0.0.1:8000/. See demo/README.md for a fuller walkthrough.
Development Setup
This project uses uv for dependency management.
# Install dependencies (including dev extras)
uv sync --extra dev
# Install pre-commit hooks
uv run pre-commit install
uv run pre-commit install --hook-type commit-msg
# Run tests
uv run pytest
# Run type checking
uv run mypy src
Metadata
Release files for django-hx-forms 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_hx_forms-1.0.0.tar.gz | 18.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_hx_forms-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 29.9 kB
Release files / django_hx_forms-1.0.0.tar.gz
| Download URL | django_hx_forms-1.0.0.tar.gz |
|---|---|
| Size | 18.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1735e3a8e9a21c0877e56c943cad8a0e72b597d8a2230a7c053f5cac2c968ca8
|
|
BLAKE2b-256 checksum How to use checksums |
db2d76cb46abeff115dfbcea8601eccc565372a978e72818e4f94ec566919b3b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 3, 2026.
Transparency logRelease files / django_hx_forms-1.0.0-py3-none-any.whl
| Download URL | django_hx_forms-1.0.0-py3-none-any.whl |
|---|---|
| Size | 11.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7f38d88100f107d3c001d6a3827130bbcab5c549c7c22b94b13fe77328d8526e
|
|
BLAKE2b-256 checksum How to use checksums |
2448c7524e265d4f718cbd482a535f2dc58d48c2f0a17e340a3ff0c390649d14
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.12
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 3, 2026.
Transparency log