Single-pages without the single-page with django2-tables, django-filter, and htmx
Introduction
I've been meaning to use htmx since it came out, but I've never had time, nor the occasion. Now the opportunity finally came to refactor an old Django view which uses the Datatable jQuery plugin.
I wanted to try something fresh, and htmx seemed the way to go, paired with a couple of great libraries: django2-tables and django-filter.
Let's see how they play well together!
Please, take this post as personal notes, don't expect a step-by-step tutorial :-)
The theory
htmx is a JavaScript library for building dynamic user interfaces which lets you enrich HTML elements with "magic" attributes.
With htmx, any actionable HTML element can make XHR
requests. The magic comes from two htmx attributes:
hx-get
hx-target
hx-get
basically says: when the user clicks this element, make a GET
request to the given URL, then swap the content of hx-target
with the partial response. (POST
requests are also supported).
The basic principle behind htmx used in the context of server-side web frameworks is the following: if the frontend request comes from htmx, we return a partial HTML fragment instead of the whole document.
In Django, this translates to: if the request comes from htmx, we return a partial template instead of the whole HTML template.
This approach can help give a single-page feeling to traditional web applications.
The recipe
My use case is a sortable/filterable table built with django2-tables and django-filter. For this view I needed a single-page feeling, but zero time to build one!
The building blocks used in this post are:
- Django
- django2-tables which gives nice datatables out of the box
- django-filter which adds filtering capabilities to Django views
- htmx
- django-htmx, a set of nice tools to integrate htmx into Django
The view
The Django view I used in the project looks more or less like this:
class ProductList(SingleTableMixin, FilterView):
table_class = ProductTable
template_name = "products/product_list.html"
model = Product
filterset_class = ProductFilter
def get(self, request, *args, **kwargs):
if request.htmx:
self.template_name = "products/htmx/table.html"
return super().get(request, *args, **kwargs)
def get_queryset(self):
return Product.objects.all()
This view uses SingleTableMixin
from django2-tables, and subclasses FilterView
from django-filters, as described in Filtering data in your table.
For the scope of this post I'll gloss over how ProductFilter
is configured: if you want to try things out, a simple configuration as described in the django-filter tutorial will suffice.
What's worth noting in this view is the get
method: if the request comes from htmx, it swaps the Django template to return a partial from products/htmx/table.html
, instead of the whole products/product_list.html
.
Here I'm also using HtmxMiddleware from django-htmx which adds the htmx
object to each request. Very nice abstraction.
The templates
Let's now take a look at the templates. To make this work we need two templates:
products/product_list.html
products/htmx/table.html
The first template, products/product_list.html
, simply includes the table partial:
{% extends "base.html" %}
{% load static %}
{% load render_table from django_tables2 %}
{% block content %}
{% include "products/htmx/table.html" %}
{% endblock %}
The second template instead, products/htmx/table.html
, is the partial:
{% load render_table from django_tables2 %}
{% render_table table %}
This is the partial template which gets returned from Django for any request coming from a htmx call.
Let's now see how all the pieces fall into place!
Single-page-like sorting
django2-tables adds links to each sortable column. This is how a header cell looks like:
<th class="orderable">
<a href="?sort=user__name">
Name
</a>
</th>
To get this under the control of htmx we need to add the two attributes you saw in the introduction:
hx-get
hx-target
Now, you can add these attributes to the table header cells rendered by django2-tables by subclassing the default template django_tables2/table.html
in products/htmx/table.html
. This is how the template should look like:
{% extends "django_tables2/table.html" %}
{% load render_table from django_tables2 %}
{% load querystring from django_tables2 %}
{% block table.thead %}
{% if table.show_header %}
<thead {{ table.attrs.thead.as_html }}>
<tr>
{% for column in table.columns %}
<th {{ column.attrs.th.as_html }}>
{% if column.orderable %}
<a hx-get="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}"
hx-target=".table-container"
href="#">{{ column.header }} </a>
{% else %}
{{ column.header }}
{% endif %}
</th>
{% endfor %}
</tr>
</thead>
{% endif %}
{% endblock table.thead %}
{% render_table table %}
This is the most important part:
<a hx-get="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}"
hx-target=".table-container"
href="#"> {{ column.header }} </a>
These attributes instruct htmx to send a GET
request to Django, which will return the partial template, and to swap the content of the element .table-container
with the partial response.
Single-page-like filtering
The principle to make django-filter work with htmx is the same you already saw above, but this time we need to change products/product_list.html
, which is the container template where the filter form lives.
To give the filter a single-page-like feeling, you can render django-filter form as follows:
{% extends "base.html" %}
{% load static %}
{% load render_table from django_tables2 %}
{% block content %}
{% if filter %}
<form hx-get="" hx-target=".table-container" method="get">
# Your django-filter fields here ...
<button type="submit">Apply filters</button>
</form>
{% endif %}
{% include "products/htmx/table.html" %}
{% endblock %}
Again, you can see how the form is progressively enhanced with htmx:
<form hx-get="" hx-target=".table-container" method="get">
Now, any filtered request coming from the form will be controlled by htmx, which will swap the target container .table-container
with the partial response.
Note: .table-container
gets added out-of-the-box by django2-tables
Single-page-like pagination
Coming soon
Is htmx any good?
I should admit that htmx is great for adding the single-page-like feeling to Django views and I quite enjoy working with it. I should also add that at this stage I've still not enough elements to judge its scalability and maintainability, especially for more complex use cases. Time will tell!
Update: after using it on a bunch of projects, I'm more and more convinced that htmx is a great tool for building MVPs, but it's also important to not take it to the extremes.
Thanks for reading and stay tuned!
Links
- django2-tables which gives nice datatables out of the box
- django-filter which adds filtering capabilities to Django views
- htmx
- django-htmx, a set of nice tools to integrate htmx into Django