Skip to main content

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 matching hx-post, hx-target and hx-indicator attributes to each field in Meta.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 *_fields plurals), set_field_required(), set_field_queryset(), set_field_initial(), remove_field(), add_htmx_field_error().
  • HtmxModelForm — HtmxFormMixin combined with Django's ModelForm.

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)

Source distribution for django-hx-forms 1.0.0
File Size Uploaded
django_hx_forms-1.0.0.tar.gz 18.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-hx-forms 1.0.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page