Skip to main content

Django forms mixin for managing form updates using htmx triggers

Project description

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().
  • HtmxModelFormHtmxFormMixin 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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_hx_forms-1.0.0.tar.gz (18.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_hx_forms-1.0.0-py3-none-any.whl (11.1 kB view details)

Uploaded Python 3

File details

Details for the file django_hx_forms-1.0.0.tar.gz.

File metadata

  • Download URL: django_hx_forms-1.0.0.tar.gz
  • Upload date:
  • Size: 18.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for django_hx_forms-1.0.0.tar.gz
Algorithm Hash digest
SHA256 1735e3a8e9a21c0877e56c943cad8a0e72b597d8a2230a7c053f5cac2c968ca8
MD5 392c49a6df34c62bfefa91deb2f19315
BLAKE2b-256 db2d76cb46abeff115dfbcea8601eccc565372a978e72818e4f94ec566919b3b

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_hx_forms-1.0.0.tar.gz:

Publisher: publish.yml on howieweiner/django-hx-forms

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_hx_forms-1.0.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_hx_forms-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7f38d88100f107d3c001d6a3827130bbcab5c549c7c22b94b13fe77328d8526e
MD5 4e04bf91a4037c5fdf8ac39ae12f38c4
BLAKE2b-256 2448c7524e265d4f718cbd482a535f2dc58d48c2f0a17e340a3ff0c390649d14

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_hx_forms-1.0.0-py3-none-any.whl:

Publisher: publish.yml on howieweiner/django-hx-forms

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page