Django Formsets Tutorial - Build dynamic forms with Htmx

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 authors in 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:

Developer tools of Django formset

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:

htmx create form

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 test form submission

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: