Django Formsets Tutorial - Build dynamic forms with Htmx
This tutorial will cover how to build dynamic forms in Django using Htmx. It will also cover the basic concepts of Django formsets. But most of all, we're going to focus on how to make dynamic forms look and feel good.
You can find the code from this tutorial in this GitHub repository
If you want to watch the video instead of reading:
Project Setup
Start by creating a Django project:
virtualenv env
source env/bin/activate
pip install django
django-admin startproject djforms
The latest version of Django at the time of this tutorial is 3.2.6
Run the migrations:
python manage.py migrate
Models
For this project we will work with the same set of models. Create a Django app and register it in the settings:
python manage.py startapp books
INSTALLED_APPS = [
# ...
'django.contrib.staticfiles',
'books'
]
Add it to INSTALLED_APPS in settings.py
Inside books/models.py
add the following models:
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=50)
def __str__(self):
return self.name
class Book(models.Model):
author = models.ForeignKey(Author, on_delete=models.CASCADE)
title = models.CharField(max_length=100)
number_of_pages = models.PositiveIntegerField(default=1)
def __str__(self):
return self.title
And add the following to books/admin.py
:
from django.contrib import admin
from .models import Author, Book
class BookInLineAdmin(admin.TabularInline):
model = Book
class AuthorAdmin(admin.ModelAdmin):
inlines = [BookInLineAdmin]
admin.site.register(Author, AuthorAdmin)
Using these models we can create an author and add as many books as we want to that author.
Run the migrations:
python manage.py makemigrations
python manage.py migrate
How to use Django Formsets
Formsets are one of the best parts of Django. The idea behind formsets is that you get a really flexible rendering of forms in your template and you don't have to write a lot of code to achieve it.
A formset is a layer of abstraction to work with multiple forms on the same page - Django docs
Formset factories are the main tools you can use to create formsets in Django:
from django.forms.models import (
inlineformset_factory,
formset_factory,
modelform_factory,
modelformset_factory
)
Create a file forms.py
inside the books
app and add the following:
from django import forms
from .models import Book
class BookForm(forms.ModelForm):
class Meta:
model = Book
fields = (
'title',
'number_of_pages'
)
We'll use the inlineformset_factory
to create the formset but the other functions work pretty much the same way. The only difference is that modelform_factory
and modelformset_factory
work specifically with forms that inherit from forms.ModelForm
.
Inside forms.py
add the following:
from django.forms.models import inlineformset_factory
from .models import Author
...
BookFormSet = inlineformset_factory(
Author,
Book,
form=BookForm,
min_num=2, # minimum number of forms that must be filled in
extra=1, # number of empty forms to display
can_delete=False # show a checkbox in each form to delete the row
)
Here we are creating an inline formset. The first argument is the parent model, which in this case is the Author
. The second argument is the child model which is the Book
. The form argument is the form used to create Book
instances, and the other arguments change the styling of the form.
We'll now use this form in a function-based view. Inside books/views.py
add the following:
from django.shortcuts import redirect, render
from .forms import BookFormSet
from .models import Author
def create_book(request, pk):
author = Author.objects.get(id=pk)
books = Book.objects.filter(author=author)
formset = BookFormSet(request.POST or None)
if request.method == "POST":
if formset.is_valid():
formset.instance = author
formset.save()
return redirect("create-book", pk=author.id)
context = {
"formset": formset,
"author": author,
"books": books
}
return render(request, "create_book.html", context)
In this view we create an instance of the BookFormSet
and pass it into the context. If the request method is a POST
request we then pass the request into the form, check if it is valid and then call the save()
method. Because we are using a ModelForm
this will save the values of the form as Book
instances. Notice we're also assigning the instance
of the formset as the author. The instance
property is needed to link the child models to the parent.
Important to note is that this view requires the primary key of the author that we will add books to. Create a few authors in the Django admin:
python manage.py createsuperuser
Add a superuser so you can login to the admin:
Add the view to the project urls.py
:
from django.contrib import admin
from django.urls import path
from books.views import create_book
urlpatterns = [
path('admin/', admin.site.urls),
path('<pk>/', create_book, name='create-book')
]
In the root of the project create a templates
folder and inside it create create_book.html
. Add the following to it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create a book</title>
</head>
<body>
<h1>Create books for {{ author.name }}</h1>
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
{{ formset.as_p }}
<button>Submit</button>
</form>
<hr>
<h2>Books</h2>
{% for book in books %}
<p>{{ book.title }} - {{ book.number_of_pages }}</p>
{% endfor %}
</body>
</html>
Register the templates folder in the settings.py
:
TEMPLATES = [
{
...
'DIRS': [BASE_DIR / "templates"],
...
},
]
Visit http://127.0.0.1:8000/1
and you should see three forms to create books as well as the heading showing Create books for Joe
.
Inspect the page and go to the Elements
tab in the developer tools - you should see the following:
Django's formsets include a lot of hidden fields. The {{ formset.management_form }}
renders them in the template. These fields are very important because they provide Django with meta information about the forms. To understand how to make dynamic formsets it is important to understand how the forms are rendered.
Create some books
Fill in the book form and submit it. You should see the newly created books display at the bottom of the page.
How to setup Htmx with Django
Htmx is a library that allows you to access modern browser features directly from HTML, rather than using JavaScript.
There are many examples of how to use Htmx for things like deleting table rows, progress bars, file uploads and much more.
So the question is; how do you use Htmx for dynamic forms?
There are some packages available to setup Htmx with Django. However, we are going to install it from scratch. You can also follow the official Htmx installation docs.
Create a base html file
We will use a base.html
for all the other templates to inherit from so that they all contain the required files for Htmx. Create templates/base.html
and add the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Htmx Formsets</title>
<script src="https://unpkg.com/htmx.org@1.5.0"
integrity="sha384-oGA+prIp5Vchu6we2YkI51UtVzN9Jpx2Z7PnR1I78PnZlN8LkrCT4lqqqmDkyrvI"
crossorigin="anonymous"></script>
</head>
<body>
{% block content %}
{% endblock content %}
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
})
</script>
</body>
</html>
In the head of the document we've added the script to use the CDN for Htmx. We've also added a script at the bottom for Htmx to listen for requests and add the csrf_token
so that POST
requests are accepted.
Htmx Book Create View
The first Htmx view we'll create is the view that will return a new form.
Inside views.py
add the following view:
from .forms import BookForm
def create_book_form(request):
form = BookForm()
context = {
"form": form
}
return render(request, "partials/book_form.html", context)
Notice that we are using the BookForm
here. Not the BookFormSet
.
Add the view to the urls.py
:
from books.views import create_book_form
urlpatterns = [
# ...,
path('htmx/create-book-form/', create_book_form, name='create-book-form')
]
Now back inside create_book.html
replace everything with the following:
{% extends "base.html" %}
{% block content %}
<h1>Create books for {{ author.name }}</h1>
<button type="button" hx-get="{% url 'create-book-form' %}" hx-target="#bookforms" hx-swap="beforeend">
Add form
</button>
<div id="bookforms"></div>
{% endblock %}
We're now extending from base.html
which lets us use Htmx properties. On the button element we've added the hx-get
attribute which is pointing to the create-book-form
URL. The target is set as the div with an ID of bookforms
. Lastly the hx-swap
attribute is for configuring how the response is rendered. beforeend
will add the response to the end of the div.
When you click the button a GET request is sent to the backend where Django will return an HTML response of an empty BookForm
. The HTML response is then added to the bookforms
div.
Click the Add form button and you should see the following:
This gives a nice dynamic feeling.
To get the form submissions to work we have to change the create_book
view. It no longer works with FormSets
so it now looks like this:
def create_book(request, pk):
author = Author.objects.get(id=pk)
books = Book.objects.filter(author=author)
form = BookForm(request.POST or None)
if request.method == "POST":
if form.is_valid():
book = form.save(commit=False)
book.author = author
book.save()
return HttpResponse("success")
else:
return render(request, "partials/book_form.html", context={
"form": form
})
context = {
"form": form,
"author": author,
"books": books
}
return render(request, "create_book.html", context)
Notice the else
statement returns a render of the form with the book_form.html
template so that the form errors can be displayed.
We also have to add some functionality to book_form.html
<div hx-target="this" hx-swap="outerHTML">
<form method="POST">
{% csrf_token %}
{{ form }}
<button type="submit" hx-post=".">Submit</button>
</form>
</div>
We have wrapped the form inside a div with two Htmx properties. The hx-target
specifies this
as the target which means it is pointing to itself. The hx-swap
property has been set to outerHTML
. Combining these two properties basically means that when the form is submitted, the entire form will be replaced by the response. The hx-post
property on the button element ensures we send an Htmx request and not a normal request. The .
value means the request will be sent to the current URL.
Test the form submission. You should see the form is replaced with success
. That is because the HttpResponse
is returning success
. We can get more creative with this response by adding a detail view and returning the detail view response instead.
Htmx Book Detail View
Add the following view:
from django.shortcuts import get_object_or_404
def detail_book(request, pk):
book = get_object_or_404(Book, id=pk)
context = {
"book": book
}
return render(request, "partials/book_detail.html", context)
Add the view to the urls:
from books.views import detail_book
urlpatterns = [
...
path('htmx/book/<pk>/', detail_book, name="detail-book"),
]
And create partials/book_detail.html
:
<div>
<h3>Book Name: {{ book.title }}</h3>
<p>Number of pages: {{ book.number_of_pages }}</p>
</div>
Change the response in the create_book
view from:
return HttpResponse("success")
to:
return redirect("detail-book", pk=book.id)
This will return the detail view of the book as the response for when the form is submitted.
Test the form submission and you should see the book title and number of pages being displayed, while the form disappears.
Htmx Book Update View
Now we have the create view and detail view working. We'll add the update view so that when the book is created we can click a button to edit that book.
Create the view:
def update_book(request, pk):
book = Book.objects.get(id=pk)
form = BookForm(request.POST or None, instance=book)
if request.method == "POST":
if form.is_valid():
form.save()
return redirect("detail-book", pk=book.id)
context = {
"form": form,
"book": book
}
return render(request, "partials/book_form.html", context)
This works similarly to the create view. The main difference is that we're passing in instance=book
to the form to update the book. We're also returning partials/book_form.html
which renders the same form as in the create_view. But be careful though. In the template there's no way to distinguish between updating books and creating new books.
Update book_form.html
so that the button is different depending on if we're updating an existing book:
{% if book %}
<button type="submit" hx-post="{% url 'update-book' book.id %}">
Submit
</button>
{% else %}
<button hx-post=".">
Submit
</button>
{% endif %}
Add the view to the urls:
from books.views import update_book
urlpatterns = [
...
path('htmx/book/<pk>/update/', update_book, name="update-book"),
]
Replace the contents of book_detail.html
with the following:
<div hx-target="this" hx-swap="outerHTML">
<h3>Book Name: {{ book.title }}</h3>
<p>Number of pages: {{ book.number_of_pages }}</p>
<button hx-get="{% url 'update-book' book.id %}">Update</button>
</div>
Similar to book_form.html
, in this template we've added the attributes hx-target
and hx-swap
so that when the request is made it swaps the entire detail snippet for the response - which in this case is the populated form from the update view.
Test it out and check that the books are being updated after you save.
Htmx Book Delete View
Add the view:
def delete_book(request, pk):
book = get_object_or_404(Book, id=pk)
if request.method == "POST":
book.delete()
return HttpResponse("")
return HttpResponseNotAllowed(
[
"POST",
]
)
Add it to the urls:
from books.views import delete_book
urlpatterns = [
path('htmx/book/<pk>/delete/', delete_book, name="delete-book"),
]
Add a delete button to the book_detail.html
:
<button hx-post="{% url 'delete-book' book.id %}">Delete</button>
To make testing easier, loop through the books in the create_book.html
. Add the following inside the content block:
{% for book in books %}
{% include "partials/book_detail.html" %}
{% endfor %}
Test the delete button. You should see the book removed from the page. Check the Django admin as well to confirm that the book is deleted.
Cancel Button
When clicking to update a book there is no way to cancel and go back to the detail view.
Update book_form.html
to look like this:
<div hx-target="this" hx-swap="outerHTML">
<form method="POST">
{% csrf_token %}
{{ form }}
<button type="submit" hx-post=".">Submit</button>
{% if book %}
<button type="submit" hx-post="{% url 'update-book' book.id %}">
Submit
</button>
<button hx-get="{% url 'detail-book' book.id %}">
Cancel
</button>
{% else %}
<button hx-post=".">
Submit
</button>
{% endif %}
</form>
</div>
We've added a button that requests the detail view. It will also replace the outer HTML with the response from the request. In this way it acts like a cancel button.
Now test to update a form and then click the cancel button. It should replace the form with the detail view of the book.
Django Formsets vs Htmx
Django's Formsets are very useful. But if you want to make the formsets look and feel good, particularly when using inline formsets, then you'll need to add JavaScript. This can land up being very complex and time consuming to get right.
I spent a lot of time trying to get formsets to play nice with Htmx. But ultimately decided that these two just don't work well together. Here's why:
Brennan Tymrak's article on dynamic formsets outlines a way to dynamically render formsets using JavaScript. When it comes to making formsets dynamic:
Adding additional forms requires using JavaScript to:
- Get an existing form from the page
- Make a copy of the form
- Increment the number of the form
- Insert the new form on the page
- Update the number of total forms in the management form
To try replicate this functionality in Htmx defeats the point of using Htmx. It requires some complicated logic that might as well be done using JavaScript.
Ultimately, the solution to achieving dynamic form logic with Htmx is to not use formsets. As you've seen in this tutorial so far we haven't used formsets at all when dealing with Htmx.
While this solution might not end up with exactly the result you were looking for, in my experience the things that matter are:
- How understandable and maintainable is the code?
- Does the desired outcome solve the problem?
With what we've shown so far I believe both these boxes can be ticked.
Making Forms Look Good
One of the issues with formsets is that while they function well, they normally don't look great.
We're going to add TailwindCSS to the project to style the forms. We'll use the CDN because it is easier to test with. In production you would want to minimise the size of the CSS bundle. A project like django-tailwind can help achieve this.
Add the CDN
To base.html
add the CDN in the head tag:
<head>
<link href="https://unpkg.com/tailwindcss@^2/dist/tailwind.min.css" rel="stylesheet">
</head>
In base.html
wrap the content block like this:
<div class="py-5 px-4 sm:px-6 max-w-5xl mx-auto">
{% block content %}
{% endblock content %}
</div>
Update create_book.html
:
{% extends "base.html" %}
{% block content %}
<div class="md:flex md:items-center md:justify-between">
<div class="flex-1 min-w-0">
<h2 class="text-2xl font-bold leading-7 text-gray-900 sm:text-3xl sm:truncate">
Create books for {{ author.name }}
</h2>
</div>
<div class="mt-4 flex md:mt-0 md:ml-4">
<button type="button" hx-get="{% url 'create-book-form' %}" hx-target="#bookforms" hx-swap="beforeend"
class="ml-3 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Add form
</button>
</div>
</div>
<div id="bookforms" class="py-5 mt-5"></div>
<div class="mt-5 py-5 border-t border-gray-100">
{% for book in books %}
{% include "partials/book_detail.html" %}
{% endfor %}
</div>
{% endblock %}
Update book_form.html
:
<div hx-target="this" hx-swap="outerHTML" class="mt-3 py-3 px-3 bg-white shadow border border-gray-100">
<form method="POST">
{% csrf_token %}
{{ form }}
{% if book %}
<button type="submit" hx-post="{% url 'update-book' book.id %}"
class="inline-flex items-center px-3 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Submit
</button>
<button hx-get="{% url 'detail-book' book.id %}" type="button"
class="ml-2 inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</button>
{% else %}
<button type="submit" hx-post="."
class="inline-flex items-center px-3 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Submit
</button>
{% endif %}
</form>
</div>
Update book_detail.html
:
<div hx-target="this" hx-swap="outerHTML" class="mt-3 py-3 px-3 bg-white shadow border border-gray-100">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Book Name: {{ book.title }}
</h3>
<p class="text-gray-600">Number of pages: {{ book.number_of_pages }}</p>
<div class="mt-2">
<button hx-get="{% url 'update-book' book.id %}"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Update
</button>
<button hx-post="{% url 'delete-book' book.id %}"
class="ml-2 inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">Delete</button>
</div>
</div>
Crispy Forms
The go-to package for better forms is django-crispy-forms. We're going to use the TailwindCSS template pack for styling.
Install both packages:
pip install django-crispy-forms crispy-tailwind
Configure the package in settings.py
:
INSTALLED_APPS = (
...
"crispy_forms",
"crispy_tailwind",
...
)
CRISPY_ALLOWED_TEMPLATE_PACKS = "tailwind"
CRISPY_TEMPLATE_PACK = "tailwind"
Now in book_form.html
load the tailwind filters at the top:
{% load tailwind_filters %}
And make the form crispy:
{{ form|crispy }}
Now we have much better looking forms. Play around with the project. Maybe there are some areas you want to improve on.
Conclusion
So far Htmx has been very useful. Using it you can write simple code that significantly improves the UI experience. Dynamic forms feel like a breeze and we don't even have to work with formsets or JavaScript.
Django Pro
If you want to become a professional Django developer consider JustDjango Pro.
Links: