← ~/logs LOG-013

>Django Forms in the Age of HTMx

Single-field inline editing with HTMx and Django forms -- one form per field, a marker mixin registry, and JSON-backed plugin fields.

Hanne Moa’s talk at DjangoCon Europe 2026 on single-field form editing with HTMx. Hanne converted Argus (a production alert system) from a React SPA to Django + HTMx and distilled the patterns into a demo repo.

The idea

HTMx lets you treat any HTML tag as a form trigger. Click the displayed value, swap it with an <input>, hit Enter, server saves that field alone and swaps the value back. Django forms stay exactly as they are — no new library needed.

One form per field

Each field gets its own ModelForm with a fieldname marker. A mixin’s __subclasses__() gives you every single-field form for free:

class SingleFieldFormMixin:
    template_name = "django/forms/dl.html"
    fieldname: str

    def get_field(self):
        return self[self.fieldname]

class BookTitleForm(SingleFieldFormMixin, forms.ModelForm):
    fieldname = "title"
    class Meta:
        model = Book
        fields = ["title"]

The registry walks every subclass. Bind data only to the form whose field was submitted:

def get_forms(data=None, obj=None):
    forms = {}
    for Form in SingleFieldFormMixin.__subclasses__():
        kwargs = {"instance": obj}
        if Form.fieldname in data:
            kwargs["data"] = data
        forms[Form.fieldname] = Form(**kwargs)
    return forms

Plugin fields without migrations

A JSON-backed mixin reads/writes instance.misc[fieldname]. Adding a new editable field is adding one class — no migration, no model change:

class JSONBackedMixin:
    json_backed = True

    def __init__(self, instance=None, data=None, **kwargs):
        self.instance = instance
        if instance is not None:
            value = instance.misc.get(self.fieldname, "")
            if value:
                kwargs.setdefault("initial", {})[self.fieldname] = value
        super().__init__(data=data, **kwargs)

    def save(self, **_):
        if self.is_valid():
            self.instance.misc[self.fieldname] = self.cleaned_data[self.fieldname]
            self.instance.save()

The HTMx swap

Same view, two templates. HX-Request header tells you fragment vs full page:

def book_field_form(request, pk, fieldname):
    book = get_object_or_404(Book, pk=pk)
    form = get_forms(obj=book)[fieldname]
    template = "books/_field_form.html" if is_htmx(request) else "books/field_form.html"
    return render(request, template, {"object": book, "fieldname": fieldname, "form": form})

Key takeaways

  • One form per field + a marker mixin is enough to get an implicit registry
  • HTMx turns every <span> into a form. The server side stays 100% Django forms
  • JSONField + JSON-backed mixin = plugin fields without migrations
  • __init_subclass__ beats __subclasses__() once inheritance gets more than one level deep

Slides | Experiment code