PHP: Hypermedia-driven datatable with HTMX
Datatables are annoying. There's about ten thousand different implementations for them in Javascript, and they all suck. I was curious about building one with HTMX instead.
Datatable: A table, usually paginated, with some basic interaction like sorting, searching, etc.
HTMX: A minimalist "hypertext-driven" approach for web dev which enables selective hot-swapping of DOM elements instead of requiring a SPA or JS framework.
Prelims
Data
A list of detective novels, with title and author.
This is a deliberately teensy-tiny example, just to demonstrate the approach.
Slim PHP
I built a version of this before with Django, but I wasn't satisfied with it. Django isn't heavy, but it was also just... way too much plumbing for something as simple as a demo like this.
Instead, since I use PHP in my daily dev at $JOB, I decided to give Slim a
try. That helped to reduce the entire project down to a single index.php,
which is absolutely the correct size for something as simple as this.
The thing itself
Desired behavior
When the user clicks a heading, they should sort the table. That means we have a few states:
- A 'natural' sort, of the data in whatever order it appears naturally
- An 'ascending' or 'descending' state for each column
For this demo, we'll only allow sorting on one column at a time.
The table should be paginated (10 items at a time is fine for the demo), and the user will need a way to navigate between pages.
Slim code
The entire Slim route can be a single function:
$app->get('/', function (Request $request, Response $response) {
// First, we want to understand the request we're getting
$htmx = $request->hasHeader('HX-Request');
$params = $request->getQueryParams();
// Next, we want to check if we've received `sort` and `page`
// parameters, and honor them or default them, accordingly.
$sort = $params['sort'] ?? null;
$sortCallback = match ($sort) {
'-author' => fn($a, $b) => $b['author'] <=> $a['author'],
'author' => fn($a, $b) => $a['author'] <=> $b['author'],
'-title' => fn($a, $b) => $b['title'] <=> $a['title'],
'title' => fn($a, $b) => $a['title'] <=> $b['title'],
default => fn() => 0,
};
$page = (int) ($params['page'] ?? 1);
// Sort the books using the specified callback from the match
$books = // the data for the books
usort($books, $sortCallback);
$paginator = new Paginator($books);
return new PhpRenderer(TEMPLATES_DIR)->render(
$response,
// For HTMX, we only need to render the partial, not the whole page
$htmx ? 'table.php' : 'home.php',
// The `page` contains all the context we need for a given table
['page' => $paginator->page($page), 'sort' => $sort],
);
});
The table and graceful degradation
Graceful degradation: An HTMX-enabled webpage should function just as well (or, at least, still meet the user's needs) whether or not JavaScript is enabled.
Graceful degradation is sort of a silly concept, since pretty much nobody ever uses the internet with JavaScript disabled. However, it's still a noble and respectable goal, and it's so easy with HTMX that there's no reason not to give it a nod.
With that in mind, the datatable HTML can actually be reduced entirely to <a>
tags -- no further JavaScript required. That means that automatically, if the
user disables JavaScript or has an issue loading the HTMX dependency, the table
will still be sortable and paginated.
Implement the sorting
We can include some indicators at the top of the table with some inelegant if/else logic:
<th>
<?php if ($sort === 'title'): ?>
<a href="?sort=-title" hx-get="?sort=-title">Title ↑</a>
<?php elseif ($sort === '-title'): ?>
<a href="?sort=" hx-get="?sort=">Title ↓</a>
<?php else: ?>
<a href="?sort=title" hx-get="?sort=title">Title</a>
<?php endif; ?>
</th>
(This would be a good candidate to extract to some sort of class or template helper if I cared more about this.)
This is what's "hypermedia-driven" about the table. Note that, in a given state, the table is rendered to reflect the current state (so the current state is reflected in the HTML), and the only available action is to move to the next state (so the state transitions are reflected in the HTML).
The application state is encoded directly into the application structure -- that's hypermedia, baby.
Implement pagination
The footer has a list of pages, and it's pretty simple since we don't mind displaying them all:
<tr>
<td colspan="2">
<?php foreach (range(1, $page->totalPages) as $pagenr): ?>
<?php $target = "?page={$pagenr}" . ($sort ? "&sort={$sort}" : '') ?>
<a href="<?= $target ?>" hx-get="<?= $target ?>">
<?php if ($pagenr === $page->currentPage): ?>
<b><?= $pagenr ?></b>
<?php else: ?>
<?= $pagenr ?>
<?php endif; ?>
</a>
<?php endforeach; ?>
</td>
</tr>
Pagination is a little tricker of the two.
When the user resets the sorting, it is reasonble to return the user to the first page, since... how else would you do it? You could try to send them to the same page with the new sorting -- but if you add filtering, then that's not a determinative behavior, since you might add or remove rows.
However, once the user has sorted, they need the sorting to be respected as you move to the next page. So, we can do that in a simple hypermedia way: the pagination links just include the sort info to maintain it across actions.
Learning
This was one of the few things where I think it turned out easier to do with PHP than with Django.
HTMX is very quick once you adjust your thinking to remember that everything is just HTML templates. Because of this, PHP is actually a pretty good fit for HTMX's approach, and makes handling relatively complicated template logic pretty easy since you have all the actual language constructs available to deal with how you want to display the application state.
And, it degrades gracefully! If you disable JavaScript on the page, it still works exactly the same, which is pretty sweet.
That's hypermedia, baby.