HTMX Patterns - Preserving Query Parameters

I've been using HTMX quite a lot lately and I'm sold on the idea of the Hypertext Driven Application (HDA for short). For a thorough introduction into the topic I recommend going through the book.

When working with HTMX, I try to make use of the tools the Platform (aka the browser) provides. Part of that is making comprehensive use of URLs, which - when used correctly - make a web application sharable, discoverable and in general a good citizen in the web.

The problem

Let's say you have a list of items that's getting out of hand. To make it more manageable for the user we want to add pagination and search.

Let's first see a naive solution that does not quite work, followed by different solutions, all with different pros and cons. As always: it's a trade-off.

The naive solution

 1{% extends "layout.html" %}
 2
 3{% block content %}
 4  <h1>Contacts</h1>
 5  <form action="">
 6    <input type="search" name="q" value="{{ request.GET.q }}">
 7  </form>
 8  <ul id="results">
 9    {% for contact in contacts %}<li>{{ contact.name }} - {{ contact.email }}</li>{% endfor %}
10  </ul>
11  <div>
12    {% if contacts.has_previous %}<a href="?page={{ contacts.previous_page_number }}">Previous</a>{% endif %}
13    {% if contacts.has_next %}<a href="?page={{ contacts.next_page_number }}">Next</a>{% endif %}
14  </div>
15{% endblock content %}

This should pretty self-explanatory, so I'll just highlight the problem here: The search term is lost when we change pages, since it's not persisted in the query parameters. This is actually quite easy to forget and of course gets amplified when more query parameters are involved. Even in the official book this problem exists, when the initial paginated design is enhanced with an active search.

Using forms instead of links

 1{% extends "layout.html" %}
 2{% block content %}
 3  <h1>Contacts</h1>
 4  <form>
 5    <input type="search" name="q" value="{{ request.GET.q }}">
 6  </form>
 7  <ul id="results">
 8    {% for contact in contacts %}<li>{{ contact.name }} - {{ contact.email }}</li>{% endfor %}
 9  </ul>
10  <div>
11    {% if contacts.has_previous %}
12      <form method="get">
13        <input type="hidden" name="page" value="{{ contacts.previous_page_number }}">
14        <input type="hidden" name="q" value="{{ request.GET.q }}">
15        <button>Previous</button>
16      </form>
17    {% endif %}
18    {% if contacts.has_next %}
19      <form method="get">
20        <input type="hidden" name="page" value="{{ contacts.next_page_number }}">
21        <input type="hidden" name="q" value="{{ request.GET.q }}">
22        <button>Next</button>
23      </form>
24    {% endif %}
25  </div>
26{% endblock content %}

This might be a bit unconventional, but at least it does not suffer the same problem as the naive version. What we're doing here, is using hidden form inputs to carry the search term information when using the pagination.

Of course a pretty serious drawback is that we're now using buttons where links would be much better in terms of semantics, accessibility and UX. Having a button instead of a links removes some pretty handy browser features, like middle-clicking to open in a new tab or hovering over the link to show the destination of the link.

In terms of readability and composability I like this solution though, since there's no manual string formatting needed. All the "logic" of which parameters are being preserved is part of the HTML. If in this particular instance that's actually a good thing is questionable, though.

Using hx-include

 1{% extends "layout.html" %}
 2{% block content %}
 3  <h1>Contacts</h1>
 4  <form>
 5    <input type="search" name="q" id="search" value="{{ request.GET.q }}">
 6  </form>
 7  <ul id="results">
 8    {% for contact in contacts %}<li>{{ contact.name }} - {{ contact.email }}</li>{% endfor %}
 9  </ul>
10  <div hx-boost="true">
11    {% if contacts.has_previous %}
12      <a hx-include="#search" href="?page={{ contacts.previous_page_number }}">Previous</a>
13    {% endif %}
14    {% if contacts.has_next %}
15      <a hx-include="#search" href="?page={{ contacts.next_page_number }}">Next</a>
16    {% endif %}
17  </div>
18{% endblock content %}

As it often seems to be the case, with just a few annotations we can tweak an existing solution using HTMX attributes. As you might have noticed, in the previous examples we weren't even using HTMX for our page.

It turns out, that it's enough to add the hx-include attribute to our anchor elements alongside enabling HTMX for them using the hx-boost attribute. What this does is effectively telling HTMX to include the value(s) of the matched inputs in the request. Instead of just including specific inputs another viable approach might be to use a data attribute on the to-be-included inputs and then filter using that, i.e. hx-include="[data-preserve-query]" .

The only downsides I can see with this solution is that hovering over the links won't reveal their true destination (because the query parameter q will be added by HTMX for the actual request) and that without JavaScript enabled, we're back to the original buggy behavior.

Using a better paginator

For the previous solutions, I've been using the standard Django paginator like so:

 1def contacts(request):
 2    contacts = all_contacts
 3    if q := request.GET.get("q"):
 4        contacts = [c for c in all_contacts if q in c["name"]]
 5
 6    paginator = Paginator(
 7        object_list=contacts,
 8        per_page=10,
 9    )
10    page = paginator.get_page(request.GET.get("page"))
11    context = {"contacts": page}
12    return TemplateResponse(request, "contacts.html", context)

The "problem" with this paginator is that it is completely oblivious of the context it is running in. Just as an idea, here's an enhanced version of the standard Paginator, which is bound to the request and knows how to generate the next and previous URLs we so desperately tried to construct ourselves previously:

 1from urllib.parse import urlparse, urlunparse, urlencode
 2from django.http import HttpRequest
 3from django.template.response import TemplateResponse
 4from django.core.paginator import Paginator
 5
 6class BoundPaginator(Paginator):
 7    """A paginator that can generate URLs for the current page and the next and previous pages.
 8
 9    Unlike Django's built-in Paginator, this one can generate URLs by being passed a request and
10    a base URL. It also allows you to specify which query parameters to preserve when generating
11    URLs.
12    """
13
14    def __init__(
15        self,
16        *args,
17        page_param: str,
18        preserve: list[str],
19        request: HttpRequest,
20        base_url: str = "",
21        **kwargs,
22    ):
23        super().__init__(*args, **kwargs)
24        self.base_url = base_url
25        self.page_param = page_param
26        self.preserve = preserve
27        self.request = request
28
29    def current_page(self):
30        number = self.current_page_number()
31        return self.page(number)
32
33    def current_page_number(self) -> int:
34        number = self.request.GET.get(self.page_param) or 1
35        return int(number)
36
37    def next_page_url(self):
38        page = self.current_page()
39        if page.has_next():
40            return self.page_url_for_number(page.next_page_number())
41
42    def previous_page_url(self):
43        page = self.current_page()
44        if page.has_previous():
45            return self.page_url_for_number(page.previous_page_number())
46
47    def page_url_for_number(self, number):
48        result = urlparse(self.request.get_full_path())
49
50        query_dict = {}
51        for param in self.preserve:
52            if value := self.request.GET.get(param):
53                query_dict[param] = value
54
55        result = result._replace(
56            path=self.base_url,
57            query=urlencode(query_dict, doseq=True),
58        )
59
60        return urlunparse(result)

While this seems to be quite overkill for such a simple task, it simplifies our template quite a bit, leaving us almost where we started:

 1{% extends "layout.html" %}
 2{% block content %}
 3  <h1>Contacts</h1>
 4  <form action="/contacts">
 5    <input type="search" name="q" value="{{ request.GET.q }}">
 6  </form>
 7  <ul id="results">
 8    {% for contact in contacts %}<li>{{ contact.name }} - {{ contact.email }}</li>{% endfor %}
 9  </ul>
10  <div>
11    {% if contacts.has_previous %}
12      <a href="{{ contacts.paginator.previous_page_url }}">Previous</a>
13    {% endif %}
14    {% if contacts.has_next %}
15      <a href="{{ contacts.paginator.next_page_url }}">Next</a>
16    {% endif %}
17  </div>
18{% endblock content %}

Conclusion

So there you have it. A possibly much too thorough look at how to work with query parameters. To me, the hx-include approach probably will be just enough most of the time.

All the code for this blog is available in this repo for you to play around with. Have fun!

22