Pagination
Most UIs that display a list of user-created data will grow to the point that they should paginate their data.
On this page
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 these partial HTML responses, we use django-tables2 to paginate, sort, and format tabular data.
An Example
Here is an example of what this solution looks like in practice. We’ll go over its components in the sections below. Please also review the inline comments in the code examples.
Table Definition
First, we begin with the table definition—ExampleFakeDataTable in this example.
It subclasses BaseHtmxTable, which itself inherits from
django-tables2's Table.
This class defines the table's columns, their display names, and the template / styling used to render rows.
The column attribute names match the keys provided by
generate_example_pagination_data() (see below), and the
verbose_name specifies the label shown to the user.
The template and default styling are provided by BaseHtmxTable.Meta, so most
use cases only need to define columns when starting from BaseHtmxTable.
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 single page of
ExampleFakeDataTable based on GET parameters and a queryset.
Since we are using HTMX, ExamplePaginatedTableView only returns a partial template: the
table itself plus page navigation and page size selection—no outer layout or navigation.
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.
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)
For this styleguide example, the queryset is just an in-memory list of dicts
(generated below). In a real project you would typically return a Django QuerySet
here instead.
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 renders the full page shell
(navigation, layout, scripts) and “hosts” the partial HTML returned by
ExamplePaginatedTableView.
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 (shown below) sets up the JavaScript context and inherits from the appropriate
CommCare HQ base template. A <div> issues an initial hx-get request to
ExamplePaginatedTableView on page load (hx-trigger="load"). Subsequent
requests (page links, page size, etc.) are driven by hx- attributes inside the table
template generated by BaseHtmxTable.
{% 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 container
that receives partial HTML from ExamplePaginatedTableView. In this example, the
“Refresh Table After Request” button:
-
performs its own HTMX
hx-getto a separate URL, and -
uses
hq-hx-refresh-after="#ExampleFakeDataTable"to trigger anhqRefreshevent on the table after that request completes.
The table listens for this event and reloads itself via HTMX. In a real page, you could use the same pattern to refresh the table after submitting a filter form, applying bulk actions, or changing configuration elsewhere on the page.
Using Knockout Pagination
Legacy Knockout pattern — kept here for reference only.
Please do not write new code like this. When you next work in this area, consider replacing this example with an equivalent Alpine / HTMX pattern (if appropriate), following the Knockout → Alpine (+HTMX) migration guide , or removing it when 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:
- the HTML widget initializing the pagination
- the goToPage javascript function that retrieves data for a given page
- the django view that returns the pagination information
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.
inlinePageListOnly: true to the pagination element's params. You can see an example of this on the
HQ Dashboard page.
<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>
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());
});