Table sorting and pagination with HTMX
I recently tried HTMX for my new project – ghstats – a dashboard of Github repository traffic in a single interface for longer than 14 days. This project was planned as a self-hosted service, so I was thinking about a really simple and memory-less tech stack. Last time I played with Rust, so I decided to use it instead of NodeJS / Python. Of course, if I'm generating static HTML on the server side, I have two options for implementing table sorting and pagination: use query parameters and do everything on backend, or use some JavaScript to call data from the backend and render the table on the client side. But HTMX offers a new, third way: write all the logic on the backend and replace the necessary parts of HTML with just a few tag attributes. Let's see how it works.
Initial setup
To demonstrate table sorting and pagination, I'll be using the same stack I used for my service: axum
as a backend framework and maud
for HTML templating (it's a DSL over Rust templates).
Let's create new project with:
cargo init htmx-example && cd-html-example
Then install dependencies:
cargo add tokio axum maud --features tokio/full,maud/axum
and do initial setup in src/main.rs
file:
use axum::{response::IntoResponse, routing::get, Router};
use maud::html;
async fn index_page() -> impl IntoResponse {
html! {
h1 { "Hello, World!" }
}
}
#[tokio::main]
async fn main() {
let service = Router::new().route("/", get(index_page)).into_make_service();
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap();
axum::serve(listener, service).await.unwrap();
}
Then we cant run this with cargo watch -x run
and open http://127.0.0.1:8080
in the browser. "Hello, World!" should be displayed.
Creating a static table
Let's add crates to generate random data:
cargo add fake rand rand_chacha --features fake/derive
and define simple Contact
struct (like our application is simple CMS).
use fake::faker::company::en::*;
use fake::faker::internet::en::*;
use fake::faker::name::en::*;
use fake::faker::phone_number::en::*;
use fake::{Dummy, Fake, Faker};
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
#[derive(Debug, Clone, Dummy)]
struct Contact {
id: u64,
#[dummy(faker = "Name()")]
name: String,
#[dummy(faker = "CompanyName()")]
company: String,
#[dummy(faker = "FreeEmail()")]
email: String,
#[dummy(faker = "PhoneNumber()")]
phone: String,
}
fn get_contacts(n: usize) -> Vec<Contact> {
let mut items = Vec::with_capacity(n);
let mut rng = ChaCha8Rng::seed_from_u64(42);
for _ in 0..n {
items.push(Faker.fake_with_rng(&mut rng));
}
items
}
async fn index_page() -> impl IntoResponse {
let contacts = get_contacts(100);
println!(">> {:?}", contacts[0]);
println!(">> {:?}", contacts[1]);
html! {
h1 { "Hello, World!" }
}
}
Now on each request same random contacts will be generated, so we can use this data in our table.
get_contacts
function emulates database query in real environment.
I will also add base layout for page with PicoCSS framework to have nice look:
fn base(html: maud::Markup) -> impl IntoResponse {
html! {
html {
head {
meta charset="utf-8" {}
title { "Contacts" }
link rel="stylesheet" href="https://unpkg.com/@picocss/pico@2.0" {}
script src="https://unpkg.com/htmx.org@2.0" {}
}
body class="container" {
{ (html) }
}
}
}
}
And finally update index_page
to render contact table:
async fn index_page() -> impl IntoResponse {
let contacts = get_contacts(100);
base(html! {
table {
thead {
tr {
th { "ID" }
th { "Name" }
th { "Company" }
th { "Email" }
th { "Phone" }
}
}
tbody {
@for contact in contacts {
tr {
td { (contact.id) }
td { (contact.name) }
td { (contact.company) }
td { (contact.email) }
td { (contact.phone) }
}
}
}
}
})
}
We can request again http://127.0.0.1:8080
and see our table with random contacts (which on each request at same).
Sorting table
Now is the time to add sorting to our table. We will use HTMX to send requests to the server and get sorted HTML back. First, we need to add sorting logic to our backend. We will use query parameters to pass sorting column and direction.
To make it easier, I will add struct TableFilter
which will represent sorting and pagination parameters, for code simplicity I will parse Request manually rather than using Query
extractor and serde
crate:
use std::collections::HashMap;
use axum::extract::{Query, Request};
#[derive(Debug)]
struct TableFilter {
sort: String,
order: String,
page: u32,
per_page: u32,
}
fn parse_table_filter(req: &Request) -> TableFilter {
let qs: Query<HashMap<String, String>> = Query::try_from_uri(req.uri()).unwrap();
let sort = qs.get("sort").unwrap_or(&"name".to_string()).to_string();
let order = qs.get("order").unwrap_or(&"desc".to_string()).to_string();
let page = qs.get("page").unwrap_or(&"1".to_string()).parse().unwrap();
let per_page = qs.get("per_page").unwrap_or(&"10".to_string()).parse().unwrap();
TableFilter { sort, order, page, per_page }
}
Next, we need to create a function to render th
with HTMX attributes to handle sorting:
fn th(title: &str, qs: &TableFilter) -> maud::Markup {
let id = title.to_lowercase();
let order = if qs.sort == id && qs.order == "desc" { "asc" } else { "desc" };
let url = format!("/?sort={}&order={}&page=1&per_page={}", id, order, qs.per_page);
html! {
th scope="col" style="cursor: pointer;"
hx-trigger="click"
hx-get=(url)
hx-target="#contacts_table"
hx-swap="outerHTML"
{
(title)
@if qs.sort == id {
span style="margin-left: 0.5em;" {
@if qs.order == "asc" { "↑" } @else { "↓" }
}
}
}
}
}
Update get_contacts
to accept TableFilter
and sort items / do pagination:
fn get_contacts(n: usize, qs: &TableFilter) -> (Vec<Contact>, u32) {
let mut items: Vec<Contact> = Vec::with_capacity(n);
let mut rng = ChaCha8Rng::seed_from_u64(42);
for _ in 0..n {
items.push(Faker.fake_with_rng(&mut rng));
}
items.sort_by(|a, b| {
let cmp = match qs.sort.as_str() {
"id" => a.id.cmp(&b.id),
"name" => a.name.cmp(&b.name),
"company" => a.company.cmp(&b.company),
"email" => a.email.cmp(&b.email),
"phone" => a.phone.cmp(&b.phone),
_ => a.id.cmp(&b.id),
};
if qs.order == "asc" {
cmp
} else {
cmp.reverse()
}
});
let pages = (items.len() as f64 / qs.per_page as f64).ceil() as u32;
let range_s = (qs.page as usize - 1) * qs.per_page as usize;
let range_e = qs.page as usize * qs.per_page as usize;
let items = items[range_s..range_e].to_vec();
(items, pages)
}
And finally, we can update our index_page
to use TableFilter
and th
:
async fn index_page(req: Request) -> impl IntoResponse {
let qs = parse_table_filter(&req);
let (contacts, pages) = get_contacts(100, &qs);
base(html! {
// id added to tell HTMX where to put new data
table id="contacts_table" {
thead {
tr {
(th("ID", &qs))
(th("Name", &qs))
(th("Company", &qs))
(th("Email", &qs))
(th("Phone", &qs))
}
}
// same as before
}
})
}
That's it! Now we can sort our table by clicking on the column header. HTMX will send a request to the server with sorting parameters and replace the table with the new sorted data. Let's me explain HTMX attributes:
hx-trigger
- event which will trigger the request, in our case it'sclick
hx-get
- URL to send the request and HTTP method (can be GET or POST)hx-target
- CSS selector to replace with new datahx-swap
- how to replace the target, in our case it'souterHTML
which will replace the whole table (by default it's inserting new data into the target)
All this attributes handled automatically by HTMX, so we just need to include HTMX script in <head>
.
Pagination
In previous step we already parse pagination filters to struct, so lets add code to render pagination links. I will use code from my another react
project, which originally from StackOverflow answer:
// https://stackoverflow.com/a/70263913
fn calc_pagination(page: usize, total: usize, len: usize) -> Vec<Option<usize>> {
use std::cmp::{max, min};
let len = if len == 0 { 5 } else { len };
let total = max(total, page);
let start =
max(1, min(page as isize - ((len - 3) as isize / 2), total as isize - len as isize + 2))
as usize;
let end = min(total, max(page + (len - 2) / 2, len - 1));
let mut result = Vec::new();
if start > 2 {
result.push(Some(1));
result.push(None);
} else if start > 1 {
result.push(Some(1));
}
for i in start..=end {
result.push(Some(i));
}
if end < total - 1 {
result.push(None);
result.push(Some(total));
} else if end < total {
result.push(Some(total));
}
result
}
Let's define two more helper function which will render pagination link and delimiter (like ...
):
fn pagination_link(qs: &TableFilter, i: u32) -> maud::Markup {
let url = format!("/?sort={}&order={}&page={}&per_page={}", qs.sort, qs.order, i, qs.per_page);
html! {
li {
a href=(url)
hx-trigger="click"
hx-get=(url)
hx-target="#contacts_table"
hx-swap="outerHTML"
{ (i) }
}
}
}
fn pagination_delim() -> maud::Markup {
html! {
li {
span { "..." }
}
}
}
And finally we can write pagination function, which will return list of links and delimiters:
fn pagination(pages: u32, qs: &TableFilter) -> maud::Markup {
let items = calc_pagination(qs.page as usize, pages as usize, 5);
html!(
nav {
ul {
@for item in items {
@if let Some(i) = item {
(pagination_link(qs, i as u32))
} @else {
(pagination_delim())
}
}
}
}
)
}
async fn index_page(req: Request) -> impl IntoResponse {
// ...
base(html! {
// ..
// add pagination call after table
(pagination(pages, &qs))
})
}
Partial HTML updates
Before this step, we always replaced the whole page with new HTML content. This can be done without HTMX at all, so what the point of using it? It's right, we have very simple layout before. Let's make it more complex to show nice feature of HTMX – partial updates.
First of all let's move table generation into separate function:
fn get_contacts_table(contacts: Vec<Contact>, qs: &TableFilter, pages: u32) -> maud::Markup {
html! {
// id moved from table to div
div id="contacts_table" {
table {
thead {
tr {
(th("ID", &qs))
(th("Name", &qs))
(th("Company", &qs))
(th("Email", &qs))
(th("Phone", &qs))
}
}
tbody {
@for contact in contacts {
tr {
td { (contact.id) }
td { (contact.name) }
td { (contact.company) }
td { (contact.email) }
td { (contact.phone) }
}
}
}
}
(pagination(pages, &qs))
}
}
}
Now we can call this code in any other place and have contacts table. Now let's add some navigation and other content to our main page:
async fn index_page(req: Request) -> impl IntoResponse {
let qs = parse_table_filter(req);
let (contacts, pages) = get_contacts(1000, &qs);
base(html! {
nav {
ul {
li { a href="/dashboard" { "Dashboard" } }
li { a href="/" { "Contacts" } }
li { a href="/settings" { "Settings" } }
}
}
h1 { "Contacts" }
(get_contacts_table(contacts, &qs, pages))
})
}
Right now in the application we have a simple navigation. But when we interact with the table, a full page will be returned, and the result will look like a nested page in a nested page... This is wrong. Let's fix this with HTMX (and this is actually where HTMX comes power).
When browser loads the page it load as regular page with default headers. When request send by HTMX it will set extra headers like HX-Request
and HX-Target
. We can use this headers to detect which part of HTML should be returned. Let's add this code to our index_page
:
fn get_hx_target(req: &Request) -> Option<&str> {
match req.headers().get("hx-target") {
Some(x) => Some(x.to_str().unwrap_or_default()),
None => None,
}
}
async fn index_page(req: Request) -> impl IntoResponse {
let qs = parse_table_filter(&req);
let (contacts, pages) = get_contacts(1000, &qs);
match get_hx_target(&req) {
Some("contacts_table") => return get_contacts_table(contacts, &qs, pages).into_response(),
_ => {}
}
base(html! {
// ... this part is the same
})
.into_response() // added into_response() to relax rust type inference
}
So in this code we check if hx-target
header is present. If not – we return full page. If it's present and equal to contacts_table
– we return only table. This is how partial updates works in HTMX. We can split page to smaller parts and update it independently.
Conclusion
HTMX is good utility for simple application which allows to add some interactivity without writing a lot of JavaScript code. It easy to use and can be integrated with any backend language, because not require any special server-side support. It's also very lightweight and can be used in any project without any additional dependencies.
PS. Source files of this article can be found here.