Single-pages without the single-page with django2-tables, django-filter, and htmx

Building sortable/filterable tables with django2-tables, django-filter, and htmx!

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:

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!

Valentino Gagliardi

Hi! I'm Valentino! I'm a freelance consultant with a wealth of experience in the IT industry. I spent the last years as a frontend consultant, providing advice and help, coaching and training on JavaScript, testing, and software development. Let's get in touch!

More from the blog: