Photo by Iรฑaki del Olmo on Unsplash
Django + htmx
Get reactivity to your Django app without a JS framework or JS itself
Table of contents
- Quick intro
- A bit of background
- Common scenarios to use htmx
- Time for some action
- Step 1: Bring up a Django project
- Step 2: Create & Setup a Book app
- Step 3a: Add required template files
- Step 4: Make a few additions to the main project
- Step 5: Let's set up htmx in our bookshelf ๐
- Step 6: Adding htmx code to our book app
- Closing thoughts
- References
Quick intro
Django is an extremely powerful web framework that comes with nuts and bolts so that you can just focus on what you need to. If you are from a python background, I'm sure you would have used one of Flask or Django or FastAPI.
I've used it quite a bit to build many solutions, and I was always stunned by the neatness of class-based views, the minimal code that one had to write, the amount of documentation it has, and packages in PyPi that work for any situation one might have.
For the frontend, I use simple HTML and CSS making use of Django template framework, no fancy heavy-duty JS frameworks. Not that they are bad, just that I use a framework depending on the need, and not because the whole world is raving about it.
Practically speaking, every new framework/technology has a good amount of learning curve associated. If I cannot justify the need 100%, I'd prefer to stay away from it. Instead, I try and find simpler alternatives, weigh the pros and cons and make a final decision.
A bit of background
One such need for me was to have reactivity on a few screens in a web app developed using Django
. When I started to dig deeper and find ways to handle it, one option that came up was to shift to JS frameworks for the front end. But it was too overkill in my case. Another option was to use javascript. I skipped that option as it may lead to a messy code over some time.
I then dug further and came across htmx. Per the htmx website,
htmx gives you access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext
When I read more about it, I decided to go ahead with it as it is simple, easy to use and does a neat job. In short, it does not load the entire page as in a typical Django way but simply loads the target DOM which is what I wanted.
Common scenarios to use htmx
There are quite a few scenarios where we do not have to load the entire page, and where we do not have to wait for the response only after we submit the page. Let's look at a few examples:
Inline validations are quite common in any web app. It makes a lot of sense to know the error message then and there rather than submit it finally
Quick edits are also a very intuitive and easy method to modify information.
Inline deletes and bulk deletes are also becoming common, especially within a table-like structure
Dynamic search is also considered a de facto these days
"Load More" button or "Infinite Scroll" is also more usable than clicking on page numbers using regular pagination
These are a few common scenarios that I think will be applicable in most of today's web apps. To look at the complete list of use cases, please take a look at this link.
Now that we've understood the need to use htmx, let's wet our feet. What's the fun without building something, is it not?
Time for some action
Let us build a super simple version that's intended to be your version of "Good Reads", which is why I called it 'My Book Shelf". To start with, let's keep it minimal. Below are the functionalities that we will add:
Sign up
Sign in/ Sign out
Add a book to your shelf
List books on your shelf
Quick edit using htmx
Delete using htmx
Dynamic search using htmx
Step 1: Bring up a Django project
I recently wrote a post about Django, I'd recommend referring to my previous post to bring up a Django project. Make sure to name the project as mybookshelf
.
To ensure everything works fine, run the development server to see the default Django page.
Step 2: Create & Setup a Book app
Let's create a Django app and call it book
. All logic related to our bookshelf will reside in this app.
python manange.py startapp book
Add urls.py
and forms.py
under book
as they do not get added by default.
Create a class Book
in models.py
from django.contrib.auth.models import User
from django.db import models
# Create your models here.
class Book(models.Model):
options = (
("just checking out", "Just Checking Out"),
("read", "Read"),
("reading", "Reading"),
("want to read", "Want to Read"),
)
title = models.CharField(max_length=255)
author = models.CharField(max_length=255)
slug = models.SlugField(max_length=255)
description = models.TextField()
cover = models.ImageField(
upload_to="covers/", blank=True, verbose_name="Cover Image"
)
status = models.CharField(
max_length=30, choices=options, default="want to read"
)
created_by = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="books_createdby"
)
created_at = models.DateTimeField(auto_now_add=True, editable=False)
updated_by = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="books_updatedby"
)
updated_at = models.DateTimeField(auto_now=True, editable=False)
class Meta:
ordering = ("-created_at",)
def __str__(self):
return self.title
Fields in the Book
class are self-explanatory.
makemigrations
at this stage as we will add a CustomUser
class. We may get compilation errors for referencing User models. Just ignore them for the moment as we shall fix them in a minute.Let's now add the methods required in views.py
and the corresponding path
in urls.py
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse_lazy
from django.views.decorators.http import require_http_methods
from django.views.generic import CreateView, ListView
from .forms import CustomUserCreationForm
from .models import Book
# Create your views here.
""" Landing page for MyBookShelf """
class HomeView(ListView):
model = Book
template_name = "book/index.html"
""" Method invoked for Sign up """
def register(request):
if request.method == "POST":
form = CustomUserCreationForm(request.POST)
if form.is_valid():
form.save()
return redirect("book:login")
else:
form = CustomUserCreationForm()
return render(
request, "registration/register.html", {"form": CustomUserCreationForm}
)
""" Method invoked to list books """
class BookListView(LoginRequiredMixin, ListView):
model = Book
template_name = "book/book-list.html"
context_object_name = "books"
paginate_by = 10
def get_queryset(self):
return Book.objects.filter(created_by=self.request.user)
""" Method invoked to add a book """
class BookCreateView(LoginRequiredMixin, CreateView):
model = Book
template_name = "book/book_create.html"
fields = ["title", "author", "description", "cover", "status"]
def form_valid(self, form):
form.instance.created_by = self.request.user
form.instance.updated_by = self.request.user
return super().form_valid(form)
def get_success_url(self) -> str:
return reverse_lazy("book:book-list")
from django.urls import path
from . import views
app_name = "book"
urlpatterns = [
path("", views.HomeView.as_view(), name="home"),
path("login/", auth_views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
path("register/", views.register, name="register"),
path("book-list", views.BookListView.as_view(), name="book-list"),
path("book-create", views.BookCreateView.as_view(), name="book-create"),
]
Below is the code for forms.py
. At the moment I use Django's default username for authentication. Adding CustomUser at this stage so that in the future it can be easily modified to email, or more columns.
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth.models import User
class CustomUserCreationForm(UserCreationForm):
class Meta:
model = User
fields = (
"username",
"password1",
"password2",
)
Don't forget to register the model in admin.py
else it won't be displayed on your Django admin site. Keeping it simple at the moment ๐
from django.contrib import admin
from .models import Book
# Register your models here.
admin.site.register(Book)
Step 3a: Add required template files
I generally maintain templates under the main project. I then create folders, one to hold the common htmls, and one for each app. This way, all templates reside in one place and it's easier this way.
Let's create 3 folders, base, book and registration under the folder templates.
Usually I keep the base.html
and navbar.html
under the base folder. base.html
is more like a template which will be extended by all other htmls
. This is done more as a DRY (Do not repeat yourself) principle. navbar.html
holds the html code required for the navigation bar. Here's the code that is present in base.html
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %} {% endblock %}</title>
<link href="{% static 'css/main.css' %}" rel="stylesheet">
</head>
<body>
{% block content %}
{% endblock %}
</body>
</html>
And here's the code for navbar.html
<nav class="navbar navbar-expand-lg bg-body-tertiary mx-auto">
<div class="container-fluid">
<a class="navbar-brand logo fs-3" href="#">MyBookShelf</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNavDropdown" aria-controls="navbarNavDropdown" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNavDropdown">
<ul class="navbar-nav">
{% if request.user.is_authenticated %}
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="{% url 'book:book-list' %}">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'book:book-create' %}">Add a Book</a>
</li>
<li class="nav-item">
<a class="btn button-primary" href="{% url 'book:logout' %}">Sign Out</a>
</li>
<li class="nav-item ml-auto">
<a class="nav-link">Welcome {{ request.user }} !</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'book:login' %}">Sign In</a>
</li>
<li class="nav-item">
<a class="btn button-primary" href="{% url 'book:register' %}">Sign Up</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
Let's now create authentication related html files under registration
folder. We need a login.html
, a logout.html
and a register.html
. Just to keep things simple, I did not add any content to logout.html
as on logout I just map it to the homepage (book\index.html
).
Here's the code for login.html
{% extends "../base/base.html" %}
{% block title %} Sign in {% endblock %}
{% block content %}
{% include "../base/navbar.html" %}
{% if form.errors %}
<p>Your username and password does not match ๐คจ</p>
{% else %}
<h3 style="text-align: center;color: #6a00f4">Sign In</h3>
{% endif %}
<div class="container text-center">
<div class="row align-items-start">
<div class="col"></div>
<div class="col">
<div class="login-form">
<form action="{% url 'book:login' %}" method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn button-primary">Sign in</button>
</form>
</div>
</div>
<div class="col"></div>
</div>
</div>
{% endblock %}
Here's the code related to Sign up, it should be added to registration\register.html
.
{% extends "../base/base.html" %}
{% block title %} Sign in {% endblock %}
{% block content %}
{% include "../base/navbar.html" %}
{% if form.errors %}
<p>There is an error. Please correct and re-submit</p>
<ul class="errorlist">
{% for field, errors in form.errors.items %}
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
{% endfor %}
</ul>
{% else %}
<h3 style="text-align: center;color: #6a00f4">Sign Up</h3>
{% endif %}
<div class="container text-center">
<div class="row align-items-start">
<div class="col"></div>
<div class="col">
<div class="login-form">
<form action="{% url 'book:register' %}" method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn button-primary">Sign up</button>
</form>
</div>
</div>
<div class="col"></div>
</div>
</div>
{% endblock %}
Now for the default home page code, which is under book\index.html
{% extends "../base/base.html" %}
{% load static %}
{% block title %} My Book Shelf {% endblock %}
{% block content %}
{% include "../base/navbar.html" %}
<div class="container text-center" >
<div class="row align-items-start">
<div class="col col-margin">
<h3>Want to maintain your own version of Good Reads?</h1>
<p class="lead">Add books, categorize them as </p>
<p style="color: #f20089"> Want to Read / Reading / Read /Just checking</p>
<p class="lead">Sign up and keep track of your reading habits in your own book shelf !</p>
<a class="btn button-primary" href="{% url 'book:register' %}">Sign Up</a>
</div>
<div class="col col-margin">
<img src="{% static 'images/bookshelf.png' %}" alt="bookshelf" class="img-fluid">
</div>
</div>
</div>
{% endblock %}
Once the htmls are developed, we then move on to create static files. I usually create static
folder in the main project. Create 3 folders, one for css
, one for images
and one for js
.
This is how my folder structure looks like with all the folders and files.
We also need to make sure that Django is able to navigate to the templates folder. In order to do that, add the following against DIRS
property under TEMPLATES
in your base.py.
It should now look like ๐
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
Step 4: Make a few additions to the main project
Now that we are done with our Book
app, let's make sure that our main project knows about this app. We also need to make sure to add properties related to static
files and media
files.
Under
INSTALLED_APPS
inbase.py
, addbook
STATIC_URL = "static/" STATICFILES_DIRS = [BASE_DIR / "static"] MEDIA_URL = "/media/" MEDIA_ROOT = os.path.join(BASE_DIR, "media/")
LOGIN_REDIRECT_URL = reverse_lazy("book:book-list") LOGOUT_REDIRECT_URL = "/" LOGIN_URL = "/login" LOGOUT_URL = "/logout"
Make sure to update the
urls.py
like so to reference the newly created app as well as the path tostatic
andmedia
files ๐from django.conf import settings as base from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("", include("book.urls", namespace="book")), ] if base.DEBUG: urlpatterns += static(base.MEDIA_URL, document_root=base.MEDIA_ROOT)
We've now created the base project along with an app with Add and List functionality. Here is how the landing page looks like:
I signed up, and added a couple of books, and it looks like ๐
Let's now get to the most important part of this post, the htmx
part. To get a good grasp of htmx
I highly recommend going through their documentation.
Step 5: Let's set up htmx in our bookshelf ๐
Install django-htmx
in your virtual environment
pip install django-htmx
Add it to your installed apps
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_htmx",
"book",
]
Add HTMXMiddleware
to the MIDDLEWARE
section
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
]
Make sure to add the following to your base.html.
The first line is to include the actual js file. The second line is to handle csrf_token
as Django requests will not function without it.
<script src="{% static 'js/htmx.min.js' %}" defer></script>
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
})
</script>
Step 6: Adding htmx code to our book app
A few important properties of htmx
that I used are:
hx-post - Invokes a post request
hx-get - Invokes a get request
hx-delete - Invokes a delete request
hx-target - Which part of the DOM should be replaced with the response?
hx-swap - HTML to be replaced in the DOM
hx-trigger - What triggers the event?
To get a mental mapping to use htmx, I follow the steps below:
Identify the element in a page that needs reactivity
Separate the output section corresponding to that element
Move that to a separate
html
file. Usually, the norm is to create a folder calledpartials
under the usualtemplates
folder.Include the newly created
html
file in the parent file so that the contents will be replacedIn the parent
html
file, addhtmx-
related properties including the type of method to be invoked, the target element, what to swap, when to trigger and so on.
In our bookshelf, let's add the functionality of a quick edit of a few details of the book and deletion of the book without reloading the entire page.
Make the following changes to the existing book_list.html
Notice how we invoke the code to populate the table in separate book.html
{% extends "../base/base.html" %}
{% block content %}
{% include "../base/navbar.html" %}
<h3 style="text-align: center;">A peek into your book shelf!</h3>
<section id="book-list">
<div class="container">
<div class="row border-bottom pb-2 mb-3 custom-header-bg text-white">
<div class="col-2">Title</div>
<div class="col-2">Author</div>
<div class="col-2">Status</div>
<div class="col-3">Modify?</div>
<div class="col-3">Remove?</div>
</div>
</div>
{% for book in books %}
{% include "../book/partials/book.html" %}
{% empty %}
<p style="text-align: center;">No books in your shelf ๐</p>
{% endfor %}
</div>
</section>
{% endblock %}
Let's now create a file
book.html
underthe templates\partials
folder. Add the following content<div class="container"> <article> <div class="row mb-3"> <div class="col-2">{{ book.title }}</div> <div class="col-2">{{ book.author }}</div> <div class="col-2">{{ book.status }}</div> <div class="col-3"> <form hx-get="{% url 'book:quick-edit' pk=book.pk %}" hx-target="closest article" hx-swap="outerHTML"> <button class="btn button-secondary">Quick edit</button> </form> </div> <div class="col-3"> <button class="btn button-secondary" hx-confirm="Are you sure you want to delete this book?" hx-target="closest article" hx-swap="outerHTML swap:1s" hx-delete="books/{{ book.id }}/delete"> Delete </button> </div> </div> </article> </div>
In the case of the Quick edit button, we invoke a
htmx-get
to load the details of the book. The method also takes in a post method (inviews.py
) when the form is submitted. Thehx-target
maps toclosest article
in this case which refers to thearticle
tag. It need not always be a tag. It can be an identifier too.In the case of the Delete button, it is quite similar to the above except that we invoke
hx-delete
method. We also open up a small modal window that asks for a confirmation.- ๐กMake sure to invoke the correct method from
views.py
That is all, quite simple! Initially, it takes a bit to wrap your head around htmx ๐
. But that's the same with anything new, is it not? Once we understand the knack of things, it's just a matter of splitting, writing a few properties and invoking the child html
.
Since the post is quite long already, I'm not adding the code snippet here related to Dynamic search. It's quite similar to the above. I will add a reference to my Github
repo in the end, just in case you are interested to see the entire code ๐
Closing thoughts
htmx
is a powerful library that can be used with many backend frameworks. I use it with Django
, I'm sure it can be used with Flask
too. I highly recommend using it when you have a case of loading a part of the page, firing validation as and when a user types something, and other reactive behavior.
As a matter of habit, I choose a framework/library only when the need is justified 200%, otherwise to me it's a better option to stick to a lightweight alternative as long as it satisfies my requirement.
Here is a link to my Github repo.
References
Simple working example of Django + htmx : Part 1