Bulk Actions in Table with Django, htmx, and AlpineJS


14 min read

When it comes to displaying large amounts of data in a tabular format, tables are often the go-to choice for developers. However, traditional tables can be limiting in their functionality, especially when it comes to performing actions on multiple rows. This can lead to a poor user experience, as users are forced to perform actions on each row individually, leading to slow and tedious interactions.

The need for a table that supports multiple actions by the user is becoming increasingly important, especially in today's fast-paced digital environment. Users expect to be able to perform actions on multiple rows in a single click, providing a more efficient and user-friendly experience. By implementing a table that supports multiple actions by the user, developers can improve their applications' overall usability and satisfaction, making it a necessary feature for modern web development.

In this article, we will discuss how to implement a table that supports bulk actions by the user using the combination of the django-tables2, htmx, and AlpineJS libraries. The django-tables2 package is a powerful library that allows developers to create and manage tables in Django projects with minimal effort. It provides a simple and flexible way to define the columns, data, and behavior of the table. The htmx and Alpine.js packages are lightweight JavaScript libraries that allow developers to perform rich interactions and dynamic updates on web pages without the need for extensive JavasScript code.

Together, these libraries provide a powerful solution for building tables without compromising performance, scalability, and maintainability.

The Requirements

Before we start, let’s write down what we want to build. The following are the rough specification

  1. The table shall support pagination and sorting when the user clicks on the column header.

  2. The table shall have a search query form that allows the user to filter the table. The pagination and sort shall be ignored when the user performs a search.

  3. The table shall allow the user to select the row(s) and perform a specific action on those selected row(s).

    • Provide a checkbox column. The user shall be able to select individual rows by clicking on the checkbox of each row.

    • When the user clicks on the checkbox column on the table header, all the checkboxes in the rows on this page shall be selected.

    • The user shall be able to select multiple rows using the combination of the shift key and a mouse click on each row.

  4. The table shall be rendered in a way that provides a seamless interaction.

    • No page refresh on any table actions

    • A progress bar to indicate the process of fetching data on all activities that requires access to the server (pagination, sorting, filtering, etc.)

    • Provide a highlight for rows that are recently updated.

The Packages Used For This Project

To build this table, we will need to use the following packages:

  1. htmx. htmx gives access to using AJAX directly in HTML, using attributes. It is simple and, for a package this small, quite powerful.

  2. django-tables2. This Django app lets you define tables like you define Django models. It can automatically generate a table based on a Django model. It supports pagination, column-based table sorting, custom column functionality via subclassing, and many other features.

  3. django-filter. I use this package for the filtering functionality. It has APIs similar to Django's ModelForm and works well with django-tables2.

  4. django-htmx. For htmx to work, Django view needs to tell which request is made using htmx and which is not. It has a middleware that adds htmx attribute to a request object.

  5. Alpine-js. This project requires a more complex behavior, htmx alone will not be enough. Specifically, the table needs to make sorting, pagination, and filtering work together nicely. This is where Alpine-js comes in. This small javascript package allows me to store data and trigger action based on changes happening to a variable.

The Model

First, let's define a model to work with.

from django.db import models  

class Product(models.Model):  
    class Status(models.IntegerChoices):  
        ACTIVE = 1, "Active"  
        INACTIVE = 2, "Inactive"  
        ARCHIVED = 3, "Archived"  

    name = models.CharField(max_length=255)  
    category = models.CharField(max_length=255)  
    price = models.DecimalField(max_digits=10, decimal_places=2)  
    cost = models.DecimalField(max_digits=10, decimal_places=2)  
    status = models.PositiveSmallIntegerField(choices=Status.choices)  

    class Meta:
        ordering = ("pk",)  

    def __str__(self):  
        return self.name

The Table

Next, based on our model above, we are going to define the table. For this project, we need to:

  • Add a checkbox column. We do this by using tables.CheckBoxColumn(accessor="pk", orderable=False).

  • To make the checkbox column appear as the first column, we will utilize the sequence parameter on the table meta's class.

  • We also need to add a class to our rows to indicate when rows are updated. We use this class to give some animation when a user updates rows. To achieve this, we will use the row_attrs parameter on the table meta's class. To get the information of the updated rows, we will add a new parameter for the table __init__ method named selection.

  • Also, we will allow a user to select multiple rows using Shift Key and a mouse click. To achieve this, we are going to define an attribute attrs={td__input:{ "@click": "checkRange"}}. The AlpineJS package will handle the rest.

import django_tables2 as tables  

from .models import Product

def rows_higlighter(**kwargs):  
    # Add highlight class to rows 
    # when the product is recently updated.
    # Recently updated rows are in the table
    # selection parameter.  
    selected_rows = kwargs["table"].selected_rows  
    if selected_rows and kwargs["record"].pk in selected_rows:  
        return "highlight-me"  
    return ""

class ProductHTMxBulkActionTable(tables.Table):  
    # Add a checkbox column to the table.  
    selection = tables.CheckBoxColumn(accessor="pk", orderable=False,   
                                        "td__input": {  
                                            "@click": "checkRange"  
    # Status is not orderable
    status = tables.Column(accessor="status", orderable=False)

    class Meta:  
        model = Product  
        template_name = "tables/bootstrap_htmx_bulkaction.html"
        show_header = False

        # This will put the checkbox column first.  
        sequence = ("selection", "...")  

        # This will add the highlight class to the rows  
        # when the product is recently updated.
        row_attrs = {  
            "class": rows_higlighter  

        # Additional class for easier styling.  
        attrs = {"class": "table checkcolumn-table"}  

    def __init__(self, selected_rows=None, *args, **kwargs):  
        super().__init__(*args, **kwargs)  

        # The selection parameter is a list of product ids  
        # that are recently updated.
        self.selected_rows = selected_rows 

The Filter

The table supports a single query form so that the user can filter the table. We define the filter below.

from decimal import Decimal  

from django.db.models import Q  
from django.forms import TextInput  
import django_filters  

from .models import Product

# Custom widget that uses search input type
class SearchInput(TextInput):  
    input_type = "search"  

class ProductUniversalFilter(django_filters.FilterSet):  
    query = django_filters.CharFilter(  
        widget=SearchInput(attrs={"placeholder": "Search..."}),  

    class Meta:  
        model = Product  
        fields = ["query"]  

    def universal_search(self, queryset, name, value):  
        if value.replace(".", "", 1).isdigit():  
            value = Decimal(value)  
            return Product.objects.filter(Q(price=value) | Q(cost=value))  

        return Product.objects.filter(  
            Q(name__icontains=value) | Q(category__icontains=value)  

I made a simple custom widget to change the input_type to search. The main basic differences come in the way browsers handle them. The first thing to note is that some browsers show a cross icon that can be clicked on to remove the search term instantly if desired.

The Views

Now that we have our model, table, and filter, we can start implementing our view. First, we need to implement a view that renders the table.

from django_tables2 import SingleTableMixin  
from django_filters.views import FilterView  

from .models import Product  
from .tables import ProductHTMxBulkActionTable
from .filters import ProductUniversalFilter

class ProductHTMxBulkActionView(SingleTableMixin, FilterView):  
    table_class = ProductHTMxBulkActionTable  
    queryset = Product.objects.all()  
    filterset_class = ProductUniversalFilter  
    paginate_by = 10  

    def get_template_names(self):  
        if self.request.htmx:  
            template_name = "products/product_table_partial.html"  
            template_name = "products/product_table_bulkaction.html"  

        return template_name  

    def get_table_kwargs(self):  
        # Get the list of recently updated products.  
        # Pass the list to the table kwargs.
        kwargs = super().get_table_kwargs()  
        selected_rows = self.request.GET.get("selection", None)  
        if selected_rows:  
            selected_rows = [int(_) for _ in selected_rows.split(",")]  
            kwargs["selected_rows"] = selected_rows  

        return kwargs

In the get_template_names, we render different templates based on whether or not the request is made via htmx. Also, since we need to pass the selected rows to our table constructor, we need to implement the get_table_kwargs.

Next, we need a view to handle the bulk update request.

from django.http import HttpResponseRedirect 
from django.utils.http import urlencode  
from django.urls import reverse_lazy  

def reverse_querystring(view, urlconf=None, args=None, kwargs=None,
                        current_app=None, query_kwargs=None):  
    '''Custom reverse to handle query strings.  
        reverse('app.views.my_view', kwargs={'pk': 123}, 
                 query_kwargs={'search': 'Bob'})
    base_url = reverse_lazy(view, urlconf=urlconf, args=args, 
                            kwargs=kwargs, current_app=current_app)  
    if query_kwargs:  
        return '{}?{}'.format(base_url, urlencode(query_kwargs))  
    return base_url

def response_updateview(request):  
    if request.method == "POST" and request.htmx:  
        # Get the selected products  
        selected_products = request.POST.getlist("selection")  

        # Check if the activate/deactivate button is pressed  
        if request.htmx.trigger_name == "activate":  
            # Activate the selected products              
        elif request.htmx.trigger_name == "deactivate":  
            # Deactivate the selected products  

        # Get the page number  
        page = request.POST.get("page", 1)  
        page = int(page)  

        # Get the sort by column  
        sort_by = request.POST.get("sort", None)

        # Get the query  
        query = request.POST.get("query", "")  

        # Get selection  
        selection = ",".join(selected_products)  

    # Redirect to table view 
    return HttpResponseRedirect(  
                            query_kwargs={"page": page, "sort": sort_by,  
                                          "query": query,
                                          "selection": selection}))

In this view, we first check what action is triggered (activate/deactivate) and update our data. Finally, we redirect to our table view, including all the needed parameters like pagination, sort, query/filter, and the selected row.

The Templates

There are three templates in play here:

  1. A template to render the whole page. Where I generate the page with an HTML header and body and the table.

  2. A template to render only the table. When we perform some action like sorting or filtering, we only need to render the partial page.

  3. Custom table template to override the default. I need to remove the table definition. And add some Alpine-js attributes for the pagination to work.

Template to Render the Entire Page

For this project, I put the table header and query form on this page. This template has a lot of functionality.

  1. Bulk activate or de-active products. To achieve this, we need to include all information like pagination, sorting, and filtering. We accomplish this using hx-include="#bulk-actions, #id_query".

  2. For searching, we included a simple search form. There are two ways to trigger the search functionality. The first is when the user types something in the search box. We delay the trigger for 500ms, and the trigger only happens when the content is changed. The second trigger is when the user clicks on the cross icon on the search box. We achieve this by using hx-trigger="keyup changed delay:500ms, search". Also, when we search, we will remove the pagination and sorting information. To achieve this, we first need to define x-data and trigger the custom clear-pagination-and-sort event before every request. To accomplish this functionality, we use x-on:htmx:before-request="$dispatch('clear-pagination-and-sort'). The custom event is defined later via the @clear-pagination-and-sort.window functionality.

  3. Pagination and sorting. I define two extra hidden input fields. The sort and page input. When a user performs an action, be it sorting, jumping between pages, or filtering, I will submit all three pieces of information back to the server.

  4. We also added functionality in the check box column, where the whole rows in that page got selected upon clicking. We achieved this using @click="toggleSelection()". Inside the toggleSelection we simply checked all rows on that page.

  5. To support multiple rows selection when the user uses Shift Key and mouse click, I added the checkRange function at the bottom.

{% extends "products/base.html" %}

{% load render_table from django_tables2 %}
{% load i18n %}
{% load django_tables2 %}

{% block bulkaction_table %}active{% endblock %}

{% block table_main %}
<h1>Product Table</h1>

<div class="table-top-container">

  {# Bulk actions and search bar #}
  <div class="row justify-content-between">
    <div class="col-4">
          <button id="activate" type="submit" name="activate"
                  class="btn btn-secondary"
                  hx-post="{% url 'tables:products_htmx_bulkaction_update' %}"
                  hx-include="#bulk-actions, #id_query">
        <button id="deactivate" type="submit" name="deactivate"
                class="btn btn-secondary"
                hx-post="{% url 'tables:products_htmx_bulkaction_update' %}"
                hx-include="#bulk-actions, #id_query">
    <div class="col-4">
      <div class="form-inline">
        <div class="d-flex justify-content-end">
          <input type="search" name="query" placeholder="Search..."
                 class="searchinput form-control" id="id_query"
                 hx-trigger="keyup changed delay:500ms, search"
                 hx-get="{% url 'tables:products_htmx_bulkaction' %}"

  {# Table header #}
  <form id="bulk-actions"
        hx-get="{% url 'tables:products_htmx_bulkaction' %}"
        hx-trigger="sort-initiated, pagination-initiated"
        x-data="{ sort_by: '',
                  page_by: 1,
                  select_all: false,
                  last_checked: false }"
        @clear-pagination-and-sort.window="page_by = 1; sort_by = ''"
        x-on:htmx:after-swap="select_all = false">

    {% csrf_token %}

    {# Hidden input to store pagination page and column to sort by #}
    <input type="hidden" name="sort" x-ref="sort_input" x-model="sort_by"
                           () => $refs.sort_input.dispatchEvent(
                                    new Event('sort-initiated',
                                              { bubbles: true })))">

    <input type="hidden" name="page" x-ref="paginate_input" x-model="page_by"
                           () => $refs.paginate_input.dispatchEvent(
                                    new Event('pagination-initiated',
                                              { bubbles: true })))">

    <table class="table checkcolumn-table header">
      <thead {{ table.attrs.thead.as_html }}>
          {% for column in table.columns %}
            {% if column.name == 'selection' %}
            <th {{ column.attrs.th.as_html }}
                x-data="{ toggleSelection(event) {
                  select_all = !select_all;
                  let checkboxes = document.getElementsByName('selection');
                  [...checkboxes].map((el) => {
                    el.checked = select_all;
              style="cursor: pointer;">
              <input type="checkbox" x-model="select_all">
            {% else %}
              {% if column.orderable %}
                <th {{ column.attrs.th.as_html }}
                    x-data="{ col_name: '{{ column.order_by_alias }}',
                    toggleSort(event) {
                      this.col_name = this.col_name.startsWith('-') ? this.col_name.substring(1) : ('-' + this.col_name);
                      sort_by = this.col_name;
                  :class="sort_by !== '' ? (sort_by === col_name ? (sort_by.startsWith('-') ? 'desc' : 'asc') : '') : ''"
                  style="cursor: pointer;">
                  {{ column.header }}
              {% else %}
                <th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
              {% endif %}
            {% endif %}
          {% endfor %}

    {# Progress indicator #}
    <div class="progress">
      <div class="indeterminate"></div>

    {# Content table #}
    {% render_table table %}
{% endblock %}

{% block footer %}
    // Set the checkbox to be checked from the start 
    // to end when the user presses the shift key.
  function checkRange(event) {
    let checkboxes = document.getElementsByName('selection');
    let inBetween =  false;
    if( event.shiftKey && event.target.checked ) {
      checkboxes.forEach( checkbox => {
        if( checkbox === event.target || checkbox === last_checked ) {
          inBetween = !inBetween;
        if( inBetween ) {
          checkbox.checked = true;
    last_checked = event.target;
{% endblock %}

Template to Render Table

Here we only render the table content, no HTML header, and no HTML body. Just the content.

{# templates/products/product_table_partial.html #}
{% load render_table from django_tables2 %}

{% render_table table %}

Template to Override the Default Table

This template extends the default table from django-tables2. It seamlessly updates the page_by variable in x-data when a user triggers any pagination action. This, in turn, updates the value of the page input, which is linked via the x-model. And it starts a custom event called pagination-initiated, which tells htmx to send the complete form back to the server, including all sorting, pagination, and filtering information. It's like magic!

{% extends "django_tables2/bootstrap4.html" %}  

{% load django_tables2 %}  
{% load i18n %}  

{% block pagination.previous %}  
<li class="previous page-item" role="button">  
    <div @click="page_by = {{table.page.previous_page_number}}"  
        <span aria-hidden="true">&laquo;</span>  
        {% trans 'previous' %}  
{% endblock pagination.previous %}  

{% block pagination.range %}  
{% for p in table.page|table_page_range:table.paginator %}  
<li class="page-item{% if table.page.number == p %} active{% endif %}" role="button">  
    <div class="page-link" {% if p != '...' %}@click="page_by={{p}}"{% endif %}>  
        {{ p }}  
{% endfor %}  
{% endblock pagination.range %}  

{% block pagination.next %}  
<li class="next page-item user-select" role="button">  
    <div @click="page_by = {{table.page.next_page_number}}" class="page-link">  
        {% trans 'next' %}  
        <span aria-hidden="true">&raquo;</span>  
{% endblock pagination.next %}


Column Width

/* Column Width */
.checkcolumn-table td:nth-child(1),  
.checkcolumn-table th:nth-child(1) {  
    width: 1%;  

.checkcolumn-table td:nth-child(2),  
.checkcolumn-table th:nth-child(2) {  
    width: 8%;  

.checkcolumn-table td:nth-child(3),  
.checkcolumn-table th:nth-child(3) {  
    width: 34%;  

.checkcolumn-table td:nth-child(4),  
.checkcolumn-table th:nth-child(4) {  
    width: 32%;  

.checkcolumn-table td:nth-child(5),  
.checkcolumn-table td:nth-child(6),
.checkcolumn-table td:nth-child(7),  
.checkcolumn-table th:nth-child(5),  
.checkcolumn-table th:nth-child(6),  
.checkcolumn-table th:nth-child(7) {  
    width: 8%;  


/* Pagination */
ul.pagination {  
  justify-content: end !important;  


/* Sorting */
th.asc:after {  
    content: '\0000a0\0025b2';  
    float: right;  
    width: 10%;  

th.desc:after {  
    content: '\0000a0\0025bc';  
    float: right;  
    width: 10%;  

Highlight Rows on Update

/* Rows highlight */
.highlight-me {  
  background-color: white;  
  animation-name: blink;  
  animation-duration: 2s;  
  transition-timing-function: ease-in;  
  transition: 0.2s;  

@keyframes blink {  
  0% { background-color: orange; color: white;}  
  50% { background-color: orange; color: white; }  
  51% { background-color: white; }  
  100% { background-color: white; }  

Progress Bar

/* Progress bar */  
.checkcolumn-table.header {  
    margin-bottom: 0;  

.progress {  
    height: 4px;  
    width: 100%;  
    border-radius: 2px;  
    background-clip: padding-box;  
    overflow: hidden;  
    position: relative;  
    opacity: 0;  

.htmx-request .progress,
.htmx-request.progress {  
    opacity: 1;  

.progress .indeterminate {  
    background-color: blue;  

.progress .indeterminate:after,  
.progress .indeterminate:before {  
    content: '';  
    position: absolute;  
    background-color: inherit;  
    top: 0;  
    left: 0;  
    bottom: 0;  
    will-change: left, right;  

.progress .indeterminate:before {  
    animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;  

.progress .indeterminate:after {  
    animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;  

@keyframes indeterminate {  
    0% { left: -35%; right: 100%; }  
    60% { left: 100%; right: -90%; }  
    100% {  left: 100%;  right: -90%; }  

@keyframes indeterminate-short {  
    0% { left: -200%; right: 100%; }  
    60% { left: 107%; right: -8%; }  
    100% { left: 107%; right: -8%; }  


In this article, we have covered the process of building a table that supports bulk actions by the user using the combination of django-tables2, htmx, and AlpineJS. We have gone through the implementation of pagination and sorting, the search query form, and the checkbox column. We also covered how to enhance the user experience by improving the table's design and layout, adding responsive design, and providing feedback to the user.

We have seen how django-tables2 is a powerful library that allows developers to create and manage tables in Django projects with minimal effort. The htmx allows developers to perform rich interactions and dynamic updates on web pages without the need for extensive JavaScript code, and Alpine.js will enable developers to add interactive functionality to their web pages with minimal code. Together, these libraries provide a powerful solution for building tables that support bulk actions by the user.