>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