Using htmx and Server-side DataTables.net together in a Django Project

Background

I really enjoy using htmx alongside Django, and try to help others when they're learning to combine these two tools. I often see questions online asking about how to combine htmx with DataTables (aka DataTables.net). Honestly, this threw me for a loop as first as well, but it's surprisingly simple to do once you see an example.

In the example below, we have a few things going on.

  1. We are loading DataTables serverside using djangorestframework-datatables. If you use another package, no worries, the details below will work just fine. DataTables is calling the DRF person list endpoint, 'myapp:create_person_view'. The endpoint returns the id, name, address, and phone for each Person
  2. We have a button that loads a form into the personFormContent div to create a new person object. I'm not showing the form, view, or template, because it's not needed for this discussion. Suffice to say, submitting the form creates a new person, and if you navigated to the DRF person list endpoint you would see the new addition. But we want that new information in the DataTable of the page we're already on (the person list view) once we submit the form.
  3. We have a couple action buttons in each row of the DataTable. Because they are generated by the DataTables JavaScript, htmx is unaware that they exist, so by default these buttons will not actually do anything when clicked.

Example (before)

Here is the basic template for the Person list view. Unfortunately, our action buttons (Update & Delete Person) do nothing, and when we submit the Person Create Form, the DataTable remains exactly as it was initialized.

{% extends "base.html" %}
{% load static i18n compress %}
{% load tz %}

{% block css %}
    <link href="{% static 'DataTables/datatables.min.css' %}" type="text/css" rel="stylesheet" />
{% endblock %}

{% block content %}

    <h2>My Person Table</h2>
    <button class="btn btn-icon btn-outline-success"
        title="New Person"
        data-hx-get="{% url 'myapp:create_person_view' %}"
        data-hx-target="#personFormContent"
        data-hx-swap="innerHTML">
        <i class="fal fa-circle-plus"></i>
    </button>

    <table id="personTable" class="table table-bordered w-100">
        <thead>
            <tr>
                <th>Name</th>
                <th>Address</th>
                <th>Phone</th>
                <th>Action Buttons</th>
            </tr>
        </thead>
        <tfoot>
            <tr>
                <th>Name</th>
                <th>Address</th>
                <th>Phone</th>
                <th>Action Buttons</th>
            </tr>
        </tfoot>
    </table>

    <div id="personFormContent"></div>

{% endblock content %}


{% block js %}

    <script src="{% static 'DataTables/datatables.js' %}"></script>

    <script>

        $(document).ready(function() {
            var personTable = $('#personTable').DataTable({
                "serverSide": true,
                "ajax": {
                    "url": "{% url 'person_drf_viewset-list' %}?format=datatables",
                },
                "columns": [
                    {
                        "data": "name",
                    },
                    {
                        "data": "address",
                    },
                    {
                        "data": "phone",
                    },
                    {  
                        "data": "id",  
                        render: function (data, type, row) {  
                            return "<button class='btn mr-1' hx-get='/person/update/" + row.id + "/' hx-target='#personFormContent' title='Update Person'></button>" +  
                            "<button class='btn mr-2' hx-get='/person/delete/" + row.id + "/' hx-target='#personFormContent' title='Delete Person'></button>";  
                        }  
                    },
                ],
            });
        });

    </script>

{% endblock js %}

Goals

To get our action buttons working and refresh the table when a Person is created, updated, or deleted, we need to do two things:

  1. Tell htmx that something has changed in the DOM, and that it needs to re-process anything in our DataTable.
  2. Tell DataTables to refresh the table content when an htmx request completes (we created, updated, or deleted something).

1. Tell htmx that something changed

htmx is blind to things HTML content in the DOM which is created dynamically by JavaScript, because htmx has already initialized and processed any nodes containing htmx attributes by the time the dynamic content is created. But we can tell htmx about the new content with htmx.process(). By default, htmx.process() re-processes the entire page, but we can provide a css selector as an argument to narrow the scope of what is re-processed. Whenever DataTables finishes initializing content, we will tell htmx to re-process the table's contents.

But where to put this?

DataTables provides an initComplete callback that fires once the table is fully initialized and the data is loaded and drawn.

So, we add the initComplete callback and use it to re-process the table.

"initComplete": function( settings, json ) {
    htmx.process('#personTable');
},

2. Tell DataTables to refresh after an htmx request

DataTables provides a way to reload the table data from the Ajax data source: ajax.reload().

And htmx has a variety of events that we can hook into, including htmx:afterRequest, which fires after an htmx request completes. Like above, we need to make sure htmx re-processed nodes after a reload, so we call htmx.process() once again.

Combining these, we can add the following to our page's JavaScript:

document.body.addEventListener('htmx:afterRequest', function(evt) {
    personDataTable.ajax.reload(function() {
        htmx.process('#personTable');
    }, false)
});

Example (after)

Here is the same template for the Person list view, except for the two changes near the bottom. Now, our action buttons fire htmx requests correctly, and when a Person is created, updated, or deleted, the table will automatically refresh with the latest content.

{% extends "base.html" %}
{% load static i18n compress %}
{% load tz %}

{% block css %}
    <link href="{% static 'DataTables/datatables.min.css' %}" type="text/css" rel="stylesheet" />
{% endblock %}

{% block content %}

    <h2>My Person Table</h2>
    <button class="btn btn-icon btn-outline-success"
        title="New Person"
        data-hx-get="{% url 'myapp:create_person_view' %}"
        data-hx-target="#personFormContent"
        data-hx-swap="innerHTML">
        <i class="fal fa-circle-plus"></i>
    </button>

    <table id="personTable" class="table table-bordered w-100">
        <thead>
            <tr>
                <th>Name</th>
                <th>Address</th>
                <th>Phone</th>
                <th>Action Buttons</th>
            </tr>
        </thead>
        <tfoot>
            <tr>
                <th>Name</th>
                <th>Address</th>
                <th>Phone</th>
                <th>Action Buttons</th>
            </tr>
        </tfoot>
    </table>

    <div id="personFormContent"></div>

{% endblock content %}


{% block js %}

    <script src="{% static 'DataTables/datatables.js' %}"></script>

    <script>

        $(document).ready(function() {
            var personDataTable = $('#personTable').DataTable({
                "serverSide": true,
                "ajax": {
                    "url": "{% url 'person_drf_viewset-list' %}?format=datatables",
                },
                "columns": [
                    {
                        "data": "name",
                    },
                    {
                        "data": "address",
                    },
                    {
                        "data": "phone",
                    },
                    {  
                        "data": "id",  
                        render: function (data, type, row) {  
                            return "<button class='btn mr-1' hx-get='/person/update/" + row.id + "/' hx-target='#personFormContent' title='Update Person'></button>" +  
                            "<button class='btn mr-2' hx-get='/person/delete/" + row.id + "/' hx-target='#personFormContent' title='Delete Person'></button>";  
                        }  
                    },
                ],

                // Use DataTables' initComplete callback to tell htmx to reprocess any htmx attributes in the table
                // DataTables docs: https://datatables.net/reference/option/initComplete
                // htmx docs: https://htmx.org/api/#process AND https://htmx.org/docs/#3rd-party
                "initComplete": function( settings, json ) {
                    htmx.process('#personTable');
                },
            });

            // Add an event listener that updates the table whenever an htmx request completes
            // DataTables docs: https://datatables.net/reference/api/ajax.reload()
            // htmx docs: https://htmx.org/events/#htmx:afterRequest
            document.body.addEventListener('htmx:afterRequest', function(evt) {
                personDataTable.ajax.reload(function() {
                    htmx.process('#personTable');
                }, false)
            });
        });

    </script>

{% endblock js %}

Refining (added 20230116)

One issue with the above is that the table will be reloaded on every htmx request.

We can narrow the scope of what triggers a DataTable reload by using the HX-Trigger response header.

In our view, when returning a response for which the table should reload, we pass along the header.

from django.http import HttpResponse

def do_something_and_reload_table(request):
    # Whatever view logic here

    # Passing an empty reponse with HX-Trigger header.
    return HttpResponse(status=204, headers={"HX-Trigger": "reloadTable"})

# Or using TemplateResponse

from django.template.response import TemplateResponse

def do_something_and_reload_table(request):
    template = "mytemplate.html"
    context = {}
    # Whatever view logic here

    # Responding with a template with HX-Trigger header.
    return TemplateResponse(request, template, context, headers={"HX-Trigger": "reloadTable"})

Instead of...

document.body.addEventListener('htmx:afterRequest', function(evt) {
    personDataTable.ajax.reload(function() {
        htmx.process('#personTable');
    }, false)
});

We now listen for the reloadTable header, and process the htmx in the same way.

document.body.addEventListener("reloadTable", function(evt){
    personDataTable.ajax.reload(function() {
        htmx.process('#personTable');
    }, false)
})

Now the DataTable will reload any time we conduct an htmx swap that sends an HX-Trigger header containing "reloadTable".

Conclusion

These same concepts can be modified and applied any time JavaScript is creating dynamic HTML content with htmx attributes and when we need to refresh page content based on htmx request completion - so long as the JavaScript library being used provides

  1. a callback when things change, and
  2. a method for updating it's AJAX-loaded content.

Hope that's helpful for anyone struggling with this.