Pagination

Most UIs that display a list of user-created data will grow to the point that they should paginate their data.

Overview

With the introduction of HTMX and Alpine as preferred front-end libraries in late 2024, and phasing out Knockout beginning in 2025, newer pages using paginated data should rely on HTMX + django-tables2 for handling pagination moving forward.

HQ has previously relied on a custom pagination component that uses Knockout Components. See pagination.js for full documentation. We will cover that example here, as it's still relevant to understand older code using this component.

Pagination with HTMX and Django Tables

As covered in the HTMX + Alpine.JS guide, HTMX works by sending partial HTML responses to asynchronous requests triggered by special hx- attributes added to buttons, forms, links, and other elements.

To assist with rendering this partial HTML template, we use django-tables2 to paginate, sort, and format tabular data.

An Example

Here is an example of what this solution looks like. We will go over its components in the sections below. Please make sure to also review the comments within the code.

Table Definition

First, we begin with the Table Definition—ExampleFakeDataTable in this example. It subclasses BaseHtmxTable, which inherits from django-tables2's Table. This object defines the table's visual style, template, and sets up the column structure and typing. The template and default styling are already taken care of by BaseHtmxTable.Meta, so most use cases just need to specify the columns when starting from BaseHtmxTable.

Python
from django.utils.translation import gettext_lazy
from django_tables2 import columns

from corehq.apps.hqwebapp.tables.htmx import BaseHtmxTable


class ExampleFakeDataTable(BaseHtmxTable):
    """
    This defines the columns for the table rendered by `ExamplePaginatedTableView`.

    The variable names for each column match the keys available in
    `generate_example_pagination_data` below, and the `verbose_name` specifies
    the name shown to the user.

    We are using the `BaseHtmxTable` parent class and defining its Meta class
    below based on `BaseHtmxTable.Meta`, as it provides some shortcuts
    and default styling for our use of django-tables2 with HTMX.
    """
    class Meta(BaseHtmxTable.Meta):
        pass

    name = columns.Column(
        verbose_name=gettext_lazy("Name"),
    )
    color = columns.Column(
        verbose_name=gettext_lazy("Color"),
    )
    big_cat = columns.Column(
        verbose_name=gettext_lazy("Big Cats"),
    )
    dob = columns.Column(
        verbose_name=gettext_lazy("Date of Birth"),
    )
    app = columns.Column(
        verbose_name=gettext_lazy("Application"),
    )
    date_opened = columns.Column(
        verbose_name=gettext_lazy("Opened On"),
    )
    owner = columns.Column(
        verbose_name=gettext_lazy("Owner"),
    )
    status = columns.Column(
        verbose_name=gettext_lazy("Status"),
    )

Table View

Next we have the Table View, ExamplePaginatedTableView . This view renders a page of the ExampleFakeDataTable based on GET parameters and a queryset.

Since we are using HTMX, ExamplePaginatedTableView only returns a partial template response that is just the table itself, page navigation, and page size selection—nothing else. The SelectablePaginatedTableView parent class handles page size selection and saving that choice in a cookie. It inherits from django-tables2's classes and mixins, which handle pagination within a given queryset.

Python
from corehq.apps.hqwebapp.tables.pagination import SelectablePaginatedTableView
from corehq.apps.styleguide.examples.bootstrap5.htmx_pagination_data import generate_example_pagination_data
from corehq.apps.styleguide.examples.bootstrap5.htmx_pagination_table import ExampleFakeDataTable


class ExamplePaginatedTableView(SelectablePaginatedTableView):
    """
    This view returns a partial template of a table, along with its
    page controls and page size selection. Its parent classes handle
    pagination of a given queryset based on GET parameters in the request.

    This view will be fetched by the "host" `HtmxPaginationView`
    via an HTMX GET request.
    """
    urlname = "styleguide_b5_paginated_table_view"
    table_class = ExampleFakeDataTable

    def get_queryset(self):
        return generate_example_pagination_data(100)

The queryset in this example is just an in-memory list of dicts for simplicity (seen below). However, django-tables2 also has support for Django QuerySets. We will be adding support for elasticsearch queries soon.

Python
from corehq.apps.prototype.utils import fake_data
from corehq.util.quickcache import quickcache


@quickcache(['num_entries'])
def generate_example_pagination_data(num_entries):
    """
    This function just generates some fake data made to look somewhat like a CommCare Case.
    """
    rows = []
    for row in range(0, num_entries):
        rows.append({
            "name": f"{fake_data.get_first_name()} {fake_data.get_last_name()}",
            "color": fake_data.get_color(),
            "big_cat": fake_data.get_big_cat(),
            "dob": fake_data.get_past_date(),
            "app": fake_data.get_fake_app(),
            "status": fake_data.get_status(),
            "owner": fake_data.get_owner(),
            "date_opened": fake_data.get_past_date(months_away=[0, 1, 2, 3]),
        })
    return rows

Host View

Lastly, we have the host view, HtmxPaginationView. This view "hosts" the partial template HTML returned from HTMX requests to ExamplePaginatedTableView.

Python
from django.utils.decorators import method_decorator
from django.urls import reverse

from corehq.apps.hqwebapp.decorators import use_bootstrap5
from corehq.apps.hqwebapp.views import BasePageView


@method_decorator(use_bootstrap5, name='dispatch')
class HtmxPaginationView(BasePageView):
    """
    This view hosts the Django Tables `ExamplePaginatedTableView`.
    """
    urlname = "styleguide_b5_htmx_pagination_view"
    template_name = "styleguide/bootstrap5/examples/htmx_pagination.html"

    @property
    def page_url(self):
        return reverse(self.urlname)

Its template (seen below) sets up the JavaScript context and inherits from the appropriate CommCare HQ base template. A div makes the initial hx-get request to ExamplePaginatedTableView on page load (hx-trigger="load"). Subsequent requests are controlled by hx- attributes within the table's template.

Django
{% extends "hqwebapp/bootstrap5/base_navigation.html" %}
{% load hq_shared_tags %}
{% load django_tables2 %}
{% load i18n %}

{# This is the basic entry point for pages using HTMX and Alpine that do not need additional JavaScript: #}
{% js_entry "hqwebapp/js/htmx_and_alpine" %}

{% block content %}
  <div class="container p-5">
    <h1 class="py-3 m-0">
      {% trans "Simple Pagination: HTMX + Django Tables2" %}
    </h1>
    <p>
      {% blocktrans %}
        This button will trigger a refresh on the table once its HTMX request is complete:
      {% endblocktrans %}
      <br />
      <button
        class="btn btn-primary" type="button"
        hx-get="{% url 'styleguide_a_hanging_view' %}"
        {# the attribute below triggers an `hqRefresh` event on `ExampleFakeDataTable` after the button's request is complete #}
        hq-hx-refresh-after="#ExampleFakeDataTable"
        {# we use none for `hx-swap` because the URL from `hx-get` above returns nothing in this example #}
        hx-swap="none"
      >
        <i class="fa fa-refresh"></i> {% trans "Refresh Table After Request" %}
      </button>
    </p>
    <div
      hx-trigger="load"
      {# `hx-get` asynchronously loads the table from HtmxPaginationView on page load, triggered by `hx-trigger` above #}
      {# The `{% querystring %}` template tag below is from django-tables2. It retrieves the GET parameters from the URL to properly initialize the table when they are present. #}
      hx-get="{% url "styleguide_b5_paginated_table_view" %}{% querystring %}"
    >
      <div class="htmx-indicator">
        <i class="fa-solid fa-spinner fa-spin"></i> {% trans "Loading..." %}
      </div>
    </div>
  </div>
{% endblock %}

{% block modals %}
  {# you can either include this template or include an extension of this template to show HTMX errors to the user #}
  {% include "hqwebapp/htmx/error_modal.html" %}
  {{ block.super }}
{% endblock modals %}

The host view can also interact with the table from outside the original div that contains the partial HTML responses from ExamplePaginatedTableView. This example demonstrates sending a refresh event to the table using the hq-hx-refresh-after attribute placed on a button. After this button's own HTMX request completes, the table is reloaded. This example is very simple, but you can imagine chaining a refresh event to a form (perhaps a column filter form) that triggers table refresh after submission.

Using Knockout Pagination

Note that these steps will be deprecated in 2025, and the section will be here for reference with older code, until it is no longer needed.

The best way to understand the different ways of using pagination is to see its use in HQ directly. The best sources for this are the Web Users and Mobile Workers pages.

For the Web Users page, the key points are:

An Example

Here is a quick example simulating the pagination that should otherwise be done asynchronously as in the Web Users example above. This is just so you have a visual reference of the pagination widget.

For small, simple UIs, you can leave the "Showing X to Y of Z entries" text and dropdown off by adding inlinePageListOnly: true to the pagination element's params. You can see an example of this on the HQ Dashboard page.
HTML
<div id="js-pagination-example">
  <ul
    class="list-group"
    data-bind="foreach: items"
  >
    <li
      class="list-group-item"
      data-bind="text: $data"
    ></li>
  </ul>
  <pagination
    data-apply-bindings="false"
    params="
      goToPage: goToPage,
      slug: 'style-guide',
      perPage: perPage,
      onLoad: onPaginationLoad,
      totalItems: totalItems
    "
  ></pagination>
</div>
JS
import $ from 'jquery';
import ko from 'knockout';
import _ from 'underscore';
import 'hqwebapp/js/components/pagination';

$(function () {
    let paginationExample = function () {
        let self = {};

        self.items = ko.observableArray();
        self.perPage = ko.observable();

        // Most of the time the widget will only deal with a page of items at a time
        // and goToPage will be an ajax call that will fetch some items and possibly a totalItems value
        self.allItems = _.map(_.range(23), function (i) { return "Item #" + (i + 1); });
        self.totalItems = ko.observable(self.allItems.length);
        self.goToPage = function (page) {
            self.items(self.allItems.slice(self.perPage() * (page - 1), self.perPage() * page));
        };

        // Initialize with first page of data
        self.onPaginationLoad = function () {
            self.goToPage(1);
        };

        return self;
    };

    $("#js-pagination-example").koApplyBindings(paginationExample());
});