Working with List Views¶
This guide covers using HtmxListView to build interactive, filterable list views.
Basic List View¶
Create a filterable list view with sorting and pagination:
from django_htmx_plus.views import HtmxListView
from myapp.models import Article
class ArticleListView(HtmxListView):
model = Article
template_name = "article/list.html"
paginate_by = 10
hx_target_id = "#article-table"
# Restrict to these fields only
fields = ("id", "title", "status", "created_at")
The view automatically handles:
URL-based filtering (
status.eq=published)Sorting (
order_by=created_atororder_by=-created_at)Pagination
Query string preservation for updates
Note
"pk" is always added to fields automatically, even if you don’t list it, so rows
stay uniquely identifiable. The <c-tables.htmx_table /> component hides the pk
column by default — pass show_pk=True in the context if you want it rendered.
Automatic PK Injection¶
Since "pk" is always added to fields at the beginning of the fields list there may be a time that you would like to show the column in a certain position. Simply add the "pk" to your field list in the HtmxListView and it will be shown regardless of the show_pk=True setting and will be shown in the position you specified.
from django_htmx_plus.views import HtmxListView
from myapp.models import Article
class ArticleListView(HtmxListView):
model = Article
template_name = "article/list.html"
paginate_by = 10
hx_target_id = "#article-table"
# Restrict to these fields only
fields = ("id", "title", "status", "pk", "created_at")
In the sample code above the PK column will be rendered second to last.
Filtering¶
Users can filter by adding query parameters:
# Filter by exact status
/articles/?status.eq=published
# Filter by title containing "django" (case-insensitive)
/articles/?title.ilike=django
# Filter by views greater than or equal to 100
/articles/?views.gte=100
# Filter by creation date range
/articles/?created_at.rng=['2024-01-01','2024-12-31']
# Multiple filters
/articles/?status.eq=published&views.gte=100&title.ilike=django
Attention
I have not implemented a cotton component to provide filtering UI elements. You can build your own filter form that submits with HTMX to make it more user-friendly until I add this feature.
See Filters for the complete filter reference.
Sorting¶
Add sorting with the order_by parameter:
# Sort by title ascending
/articles/?order_by=title
# Sort by created_at descending
/articles/?order_by=-created_at
# Combine filters and sorting
/articles/?status.eq=published&order_by=-created_at
With HtmxListView, the current sort order is tracked in the context as order_by.
Pagination¶
Pagination is automatic with paginate_by:
# Page 2
/articles/?page=2
# With filters and sorting
/articles/?status.eq=published&order_by=-created_at&page=2
The view provides:
page_obj– Current page objectpaginator– Paginator instancepage_range– Elided page numbers (shows first, last, and pages around current)
Changing the Page Size¶
Whenever paginate_by is set (so the view is paginated), <c-tables.htmx_table />
renders a “Show N entries” <select> above the table with options for 10,
25, 50, and 100 rows per page. Choosing an option issues an
hx-get with a paginate_by query parameter, which the view applies for
that request:
/articles/?paginate_by=50
Once a non-default page size is active, subsequent sort/page links (rendered by
header_cell and pager) carry the same paginate_by forward so the
chosen page size persists across sorting and paging.
Attention
The view applies paginate_by from the query string via a plain int()
conversion — there’s no clamping to the selector’s preset options or to any
min/max. If this view is reachable by untrusted users, override
get_context_data() (or setup()) to validate/clamp the requested
value before it reaches Paginator.
Using Cotton Components¶
The Cotton table component automatically integrates:
<c-tables.htmx_table class="table table-striped" />
This renders:
Sortable column headers (click to change sort)
Table rows from the queryset
Pagination controls with next/previous
Advanced: Custom Field Labels¶
Provide custom display labels for fields:
class ArticleListView(HtmxListView):
model = Article
template_name = "article/list.html"
paginate_by = 10
fields = ("id", "title", "status", "created_at", "author")
labels = {
"created_at": "Date Created",
"status": "Publication Status",
"author": "Written by",
}
The labels are used by Cotton components via context['fields'], a list of
{"name", "label", "visible"} dicts — one per field in fields. label is the
entry from labels if present and truthy, otherwise the field name run through
Django’s capfirst filter.
HtmxListView always requires you to explicitly list allowed fields (for security) —
there is no wildcard or bypass value. Any field a view needs to filter, sort, or display
must appear in fields.
Advanced: Hiding Fields¶
Include a field in the queryset and context (e.g. so get_transform_data() can use
it) without rendering a column for it:
class ArticleListView(HtmxListView):
model = Article
fields = ("id", "title", "status", "author_id")
hidden_fields = ("author_id",)
hidden_fields doesn’t remove the field from filtering, sorting, or the queryset
projection — it only sets visible: False on that field’s entry in context['fields'],
which <c-tables.htmx_table /> uses to skip rendering its header and cells.
Advanced: Custom Filtering Logic¶
For complex filtering, override get_queryset():
from django_htmx_plus.views import HtmxListView
from django.db.models import Q
class ArticleListView(HtmxListView):
model = Article
template_name = "article/list.html"
fields = ("title", "status", "created_at")
def get_queryset(self):
# Start with view's automatic filtering
queryset = super().get_queryset()
# Add custom logic
if self.request.user.is_staff:
# Staff sees all articles
pass
else:
# Others see only published articles
queryset = queryset.filter(status="published")
return queryset
The base implementation handles URL-based filters; your custom logic runs after.
Advanced: Dynamic / Custom Querysets¶
The pattern above works when you’re narrowing the view’s own queryset with one more
.filter() call. For anything more involved — a different manager,
select_related/prefetch_related, joins, or building the queryset from scratch
based on self.request — override get_queryset() without calling super() at
all, the same way you would on a plain Django ListView:
class ArticleListView(HtmxListView):
model = Article
fields = ("title", "status", "created_at")
def get_queryset(self):
if self.request.user.is_staff:
queryset = Article.objects.select_related("author")
else:
queryset = Article.published.select_related("author")
# Apply the view's URL-based filter/order_by and the `fields` projection
return self.queryset_values(queryset)
Because this override doesn’t call super().get_queryset(), none of the view’s
automatic behavior runs unless you add it back yourself. queryset_values(queryset)
is that behavior, extracted into its own method: it applies self.filter (parsed from
the URL’s field.filter_type=value query parameters), orders by self.order_by,
and restricts the projection to get_fields() (always including "pk") — the
exact steps the default get_queryset() implementation performs. Returning
self.queryset_values(queryset) from your override gets you URL-based
filtering/sorting “for free” on top of whatever custom queryset you built.
If you’d rather not apply URL-based filtering/sorting at all — or want to apply it
differently — you can skip queryset_values() and call .filter()/.order_by()
yourself instead. Just remember that skipping the final .values(*fields) step means
full model instances are returned instead of the fields-restricted dicts templates
expect (see get_transform_data() below, which receives whatever get_queryset()
returns).
Advanced: Transforming Rows¶
To post-process rows (e.g. reformat a value) without overriding get_queryset() or
get_context_data(), override get_transform_data():
class ArticleListView(HtmxListView):
model = Article
fields = ("title", "status", "created_at")
def get_transform_data(self, objects):
rows = super().get_transform_data(objects)
for row in rows:
row["status"] = row["status"].title()
return rows
It receives the current page’s queryset (or values queryset, if fields is restricted)
and must return the list of rows for the template.
Best Practices¶
Set ``hx_target_id`` for HTMX updates – helps routing of swap requests
Use specific field restrictions – only allow filtering on intended fields
Provide custom labels – makes the table headers more user-friendly
Test edge cases – empty results, invalid filters, etc.
Combine with filters form – makes filtering discoverable to users
Use triggers for modal integration – automatic table updates
Consider query performance – optimize queryset if filtering many rows