A purely hypermedia-driven datatable

At work, we use HTMX to add interactivity to our Django application. Recently, we had a need arise for an interactive “data table”: a tabular display of client data that we can search, sort, and paginate to make it easy for our clients to interact with the info.

You can find a quick little demonstration here and run it using uv run manage.py runserver, as usual for Django.

What is hypermedia?

All HTML is a form of hypermedia: an extension of plain text with nonlinear flows, connectedness, multimedia, and so on. Imagine hypermedia as “paper plus”. Powered by the internet, we can do much more with words on a screen than words on a page. A “hypermedia-driven” application is one which focuses on using hypermedia to convey information, rather than using JSON and JavaScript to render a whole application.

Less grandiosely: A hypermedia-driven application focuses on serving HTML instead of JavaScript. A typical web application using, say, React, makes a request to an endpoint, gets some JSON, deserializes it into a JavaScript object, and passes that object through various layers of functions and DOM abstractions until its content is displayed on the page. HTMX and other hypermedia libraries propose a different approach: Instead of dealing with data, why don’t we just deal with the representation itself?

HTMX returns HTML rather than JSON objects, and smartly swaps the HTML it receives in the appropriate place within the HTML document. HTMX makes apps more HTML-focused and blends very nicely with the server-side page rendering of a full-stack application like Django.

Our goal with the table

A “data table” is more than just a tabular layout. If you think about interacting with tables in, say, a Wikipedia article, you can do a lot more than just read it: You can sort, sometimes you can filter, sometimes you can click through multiple pages of results. And, all of those various functions need to work together; you don’t expect that changing the page changes the current filter, and so on.

Our goal is to build a hypermedia-driven table, powered by HTMX, which lets you do those specific actions.

  1. The user can sort the table and get the results in a different order.
  2. The user can filter the table with a search bar.
  3. The table is paginated so that it doesn’t take up too much space.

Setup

The basic object we will be using in our table is a Book. For this demo, a Book is just a collection of strings. You can find the model here. We just need some data to display in the table, so the values don’t really matter.

The good stuff

OK, now we can set up the table properly. Let’s go.

The table view

Our view which displays the table needs to handle sorting, filtering, and pagination. The canonical way to handle these things using HTTP is to add their various filters as query parameters to the URL. So, the view function needs to handle dispatching the query parameters and then bundling the results. Straightforward enough.

def books_index(request: HttpRequest) -> TemplateResponse:
    # Get the query parameters. Since `page` must be an integer for the `Paginator`,
    # default to the first page. Seems reasonable enough.
    sort = request.GET.get("sort")
    search = request.GET.get("search")
    page = request.GET.get("page", 1)

    books = Book.objects.search(search).sort(sort)

    PER_PAGE = 15
    paginator = Paginator(books, per_page=PER_PAGE)
    pg = paginator.get_page(page)

    context = {"books": pg.object_list, "page": pg, "sort": sort, "search": search}
    return TemplateResponse(request, "books/main.html", context)

Great! Now the view can handle the parameters we need. We can verify this with the following template:

<table id="datatable">
    <thead>
        {...}
    </thead>
    <tbody>
        {% for book in books %}
            <tr>
                <td>{{ book.title }}</td>
                <td>{{ book.author }}</td>
                <td>{{ book.published_on }}</td>
                <td>{{ book.series }}</td>
                <td>{{ book.order_in_series }}</td>
            </tr>
        {% endfor %}
    </tbody>
</table>

If you pass the query parameters manually into the view (such as by appending a string like ?page=3&sort=-author&search=a), you can see with just this template that the table reacts and displays the appropriate info.

This is something great about hypermedia: Because of how we design this page, we get a fixed representation of the table essentially for free. A user can share the link with another user and get the exact same conditions; and, assuming the data itself doesn’t change, can see the exact same representation. That makes the table resumable (you can refresh and return to the page and see exactly the same results) as well as easy to share.

That’s it for Django. The rest is just HTMX.

Why is there a form?

If you look at the HTML template, you’ll see that there is a form with a get method and a call to the main view. The reason for this is simple: Forms are the canonical way to store page state in pure HTML. When you GET a form to a url, it will send the form values as query parameters; exactly what we want when we make a request to this route. And, we populate the form with the existing values of the sort and search methods, so we can easily change one without changing the other.

The table, using HTMX

The table itself has quite a bit of functionality to go over. We can tackle that one piece at a time.

First is the search bar. Our expected behavior is that when the user types something into the search bar, the table will automatically adjust with that search result. So, we want to trigger the GET request on each input change from the form’s search bar.

<form id="tablecontrol"
      method="get"
      action="{% url 'main' %}"
      hx-get="{% url 'main' %}"
      hx-trigger="submit, input from:[form=tablecontrol]"
      hx-target="#datatable_container"
      hx-select="#datatable_container"
      hx-swap="outerHTML"
      hx-push-url="true">
</form>
<input form="tablecontrol" type="hidden" name="page" value="1">
{# search bar #}
<div>
    <label for="search">Search:</label>
    <input form="tablecontrol"
           type="text"
           name="search"
           value="{{ search|default_if_none:'' }}"
           class="mb">
    <button type="submit" data-hide-when-htmx-enabled="true">Submit</button>
</div>

We set the trigger on the form to include input from:[form=tablecontrol], which will watch for any input changes from the input in the search bar – exactly as we expect.

If the user adjusts the sort, we expect the user to go back to the first page. Since that is the default for the view, we can actually just leave out a page specification and get exactly that behavior. However, we want to preserve the search term when we change the sorting. We can click on the headers and see the table adjust immediately. For example:

<th>
    {% if sort == "title" %}
        <a href="{% url 'main' %}?sort=-title{% if search %}&search={{ search }}{% endif %}"
           hx-get="{% url 'main' %}?sort=-title{% if search %}&search={{ search }}{% endif %}">Title ↑</a>
    {% elif sort == "-title" %}
        <a href="{% url 'main' %}?sort={% if search %}&search={{ search }}{% endif %}"
           hx-get="{% url 'main' %}?sort={% if search %}&search={{ search }}{% endif %}">Title ↓</a>
    {% else %}
        <a href="{% url 'main' %}?sort=title{% if search %}&search={{ search }}{% endif %}"
           hx-get="{% url 'main' %}?sort=title{% if search %}&search={{ search }}{% endif %}">Title ↑↓</a>
    {% endif %}
</th>

There is almost certainly a cleaner way to handle the arrow, but since we are already doing the if/then/else block for the URLs, we might as well include it.

OK, lastly, pagination. Switching the page should not affect the sort or the search; we will need to preserve both of those as we swap the datatable. Easy enough; we can just include those parameters, if they are defined.

<div>
    {# Previous page #}
    {% if page.has_previous %}
        <a href="{% url 'main' %}?page={{ page.previous_page_number }}{% if sort %}&sort={{ sort }}{% endif %}{% if search %}&search={{ search }}{% endif %}"
           hx-get="{% url 'main' %}?page={{ page.previous_page_number }}{% if sort %}&sort={{ sort }}{% endif %}{% if search %}&search={{ search }}{% endif %}">⟨</a>
    {% endif %}
    &nbsp;
    {# Page list #}
    {% for page_nr in page.paginator.page_range %}
        <a href="{% url 'main' %}?page={{ page_nr }}{% if sort %}&sort={{ sort }}{% endif %}{% if search %}&search={{ search }}{% endif %}"
           hx-get="{% url 'main' %}?page={{ page_nr }}{% if sort %}&sort={{ sort }}{% endif %}{% if search %}&search={{ search }}{% endif %}">
            {% if page.number == page_nr %}
                <b>{{ page_nr }}</b>
            {% else %}
                {{ page_nr }}
            {% endif %}
        </a>&nbsp;
    {% endfor %}
    {# Next page #}
    {% if page.has_next %}
        <a href="{% url 'main' %}?page={{ page.next_page_number }}{% if sort %}&sort={{ sort }}{% endif %}{% if search %}&search={{ search }}{% endif %}"
           hx-get="{% url 'main' %}?page={{ page.next_page_number }}{% if sort %}&sort={{ sort }}{% endif %}{% if search %}&search={{ search }}{% endif %}">⟩</a>
    {% endif %}
</div>

Yikes! That’s some gnarly Django templating.

The fact that this gets so verbose in the Django templating language is a major code smell. Your hackles should be raised looking at this much template logic.

In a more robust solution – that is, not for a demo – consider finding a way to pass these URLs down through the context, rather than assembling them on-the-fly inside of the template. That should make the template more approachable, and logic of that nature probably belongs in the view anyway.

And… That’s a fully-functional data table using pure hypermedia.

Conclusion

On the one hand, is the hypermedia approach trivial? Absolutely not. I had to think quite a bit about exactly how the swapping should behave in order to get this to play nicely. In particlar, the fact that I had to consider how the three criteria – sort, search, and pagination – all interact was a big annoyance that we could have skipped by using a JavaScript library that already covered all of those things. However, since I didn’t want to add an entire dependency, the hypermedia table proved to be pretty easy to manage once I got my head around the swaps.

On the other hand, is the hypermedia approach easier than writing this myself in JavaScript? Absolutely. There is no deserializing here, no await, no reactive state to manage inside of the divs and the table. Having worked with React, I can grind this out with HTMX much faster. And what’s nicest is that I can do all of htis while remaining completely in the languages that the project is already using: Python and HTML.

Across the entire ReHome project, data tables like this are probably the most complicated thing we’ve had to build so far. And if “the most complicated” translates to “about two dozen cumulative lines across a single HTML file”, then we’re in a pretty great place.