in blog | Django Beats |
---|---|
original entry | A 'No JS' Solution for Dynamic Search in Django |
In this post we take advantage of HTMX requests to do partial rendering for list views in Django. Django on Fly.io is pretty sweet! Check it out: you can be up and running on Fly.io in just minutes.
Django is one of the most used server-side frameworks out there. It uses MTV (Model-Template-View) design pattern to build highly scalable and maintainable apps.
Even though Django is a very versatile framework, one of the things that annoys me the most is the fact that - for a minimal Django setup - it reloads the entire page to get a response. What if we could render individual parts of the HTML page instead of having to reload everything?
Fortunately, there are ways to accomplish that.
The first straightforward option we can think of is to use Javascript (JS). But since we all love Django and probably want to avoid having to write some JS code, I’d like to share with you another way: HTMX!
HTMX is a library created to allow us to use modern browser features - like partial rendering - directly from our HTML, rather than using Javascript. Cool, right? That’s what we are looking for.
Let’s see an example of what we’re shooting for. Check this out:
When the user types a few letters and pauses, it automatically runs the search and updates the search results. No full-page refresh used. The user’s cursor and search text remains and they can add more text to search further. The rest of this post covers how we can achieve that.
πΎ You can find the Github repo used in this guide here to follow along with the article.
We have a Django app that we can search for our favourite movies, TV shows and games - named Django IMDb. We are using some data from the OMDb, the Open Movie Database API. The app lists the titles based on our search by pressing Enter key. Our app requires a full page reload to display the results for both search functionality and pagination.
Here we have our search view with pagination:
Let’s take a look into our current code.
To simplify, we define a simple search
function based view in views.py
:
# views.py
def search(request):
search = request.GET.get('q')
page_num = request.GET.get('page', 1)
if search:
titles = Title.objects.filter(title__icontains=search)
else:
titles = Title.objects.none()
page = Paginator(object_list=titles, per_page=5).get_page(page_num)
return render(
request=request,
template_name='search.html',
context={
'page': page
}
)
Our search view receives the parameter q
that represents our search value and the page
parameter. We are filtering titles that contain the search value q
and getting the specific page if page
is set, otherwise, we get the first page. We are using a Paginator
which will facilitate the pagination in our template. Our page
object contains the list of titles.
We also define the search url in urls.py
:
# catalogue/urls.py
from django.urls import path
from . import views
urlpatterns = [
path('search/', views.search, name='search'),
]
In our search.html
page, we add our <form>
tag with an <input>
for our search:
<!-- search.html -->
<div class="search">
<form action="{% url 'search' %}" class="form">
<input name="q"
value="{{ request.GET.q }}"
placeholder="Search for a title"
>
</p>
</form>
</div>
In the form, we specify the action
attribute, which will call our search/
url when Enter key is pressed. Unless explicitly specified, the default method is GET
. To keep the search value in the input field after reloading the page, we set value="{{ request.GET.q }}"
.
In the same page, we also have our list to be displayed and a pagination:
<!-- search.html -->
<section id="results">
<div class="results">
{% for title in page.object_list %}
<div class="result">
<!-- display fields -->
...
</div>
{% endfor %}
</div>
<div class="pagination">
{% if page %}
{% if page.number != 1 %}
<a class="page first-page" href="?q={{ request.GET.q }}&page=1">
« First
</a>
{% endif %}
{% if page.has_previous %}
<a class="page" href="?q={{ request.GET.q }}&page={{ page.previous_page_number }}">
{{ page.previous_page_number }}
</a>
{% endif %}
<span class="page">
{{ page.number }}
</span>
{% if page.has_next %}
<a class="page" href="?q={{ request.GET.q }}&page={{ page.next_page_number }}">
{{ page.next_page_number }}
</a>
{% endif %}
{% if page.number != page.paginator.num_pages %}
<a class="page last-page" href="?q={{ request.GET.q }}&page={{ page.paginator.num_pages }}">
» Last
</a>
{% endif %}
{% endif %}
</div>
</section>
Now, let’s take the next steps and transform our search page!
We want to add dynamic functionality to our page without requiring a full page reload. To accomplish that, we will use one of the most popular ways today: HTMX. This library gives us access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, through attributes. If that techno soup sounds like a lot, don’t worry! Essentially, it means we can create really cool dynamic pages using the Django we love and not needing to turn to a whole other JavaScript tool-chain and framework!
We will go over some of the most common attributes in this article and show what are they used for and how to use them.
In our example, we use the django-htmx
package. Let’s take a look into it.
django-htmx
The django-htmx
package was created by Adam Johnson, one of the members of the Django Project Technical Board. django-htmx
provides us with extensions for using Django with htmx. Let’s go ahead and installed it using pip:
python3 -m pip install django-htmx==1.14.0
With the package installed, let’s add it to the INSTALLED_APPS
in our settings.py
:
# settings.py
INSTALLED_APPS = [
...
# 3rd party apps
'django_htmx',
]
And add the HtmxMiddleware
to the MIDDLEWARE
:
# settings.py
MIDDLEWARE = [
...
'django_htmx.middleware.HtmxMiddleware',
]
The middleware makes request.htmx
available in our view, which allows us to switch behavior for HTMX type requests. We will use that to distinguish the requests in our view.
django-htmx
does not include htmx
itself. You can download htmx.min.js
from it’s latest release. After that, add the Javascript file in your static directory - for our example, static/js
folder - and reference it in your base template, within the <head>
tag:
<!-- base.html -->
{% load static %}
<head>
...
<!-- Javascript -->
<script type="text/javascript" src="{% static 'js/htmx.min.js' %}" defer></script>
</head>
Note that we set the defer
attribute. This attribute specifies that the downloading happens in the background while parsing the rest of the page.
Let’s define our partial_search
view in views.py
:
# views.py
def partial_search(request):
if request.htmx:
search = request.GET.get('q')
page_num = request.GET.get('page', 1)
if search:
titles = Title.objects.filter(title__icontains=search)
else:
titles = Title.objects.none()
page = Paginator(object_list=titles, per_page=5).get_page(page_num)
return render(
request=request,
template_name='partial_results.html',
context={
'page': page
}
)
return render(request, 'partial_search.html')
As mentioned before, request.htmx
is available in our view. We use it to decide what will be performed. If the request is made with htmx, the partial_results.html
will be used, otherwise, we render partial_search.html
.
With that done, let’s add our new url:
# catalogue/urls.py
urlpatterns = [
path('partial-search/', views.partial_search, name='partial_search'),
]
Let’s check how those templates partial_search.html
and partial_results.html
look.
We will start with the partial_search.html
. Our <form>
is removed given that our <input>
can now trigger events. Since our page will not be reloaded, we can remove value="{{ request.GET.q }}"
from the input, we don’t need it anymore.
<!-- partial_search.html -->
<input name="q"
placeholder="Search for a title"
hx-get="{% url 'partial_search' %}"
hx-target="#results"
hx-trigger="input delay:0.2s"
>
...
<section id="results">
<div class="results">
{% include 'partial_results.html' %}
</div>
</section>
A few htmx attributes (hx-*
) are added to the element, let’s take a look into each of them:
hx-get
: issue a GET
request to the specific URL.
GET
request to our partial-search/
url.
hx-target
: specifies a target element for swapping - if not specified, it’s the element itself.
<section id="results">
as the target to be swapped - #results
means is the unique element with id="results"
. Since hx-swap
is not defined, the default is set to innerHTML
, which replaces everything inside the target element, <section id="results">...</section>
.
hx-trigger
: specifies the event that triggers the request.
input delay:0.2s
. The standard event is input
of the <input>
field and there will be a delay of 0.2 seconds before the event is triggered. This is just an example, customize as needed!
Now, let’s see how to replace the pagination by a Load More button.
Let’s check out another way we can leverage htmx in our website, by replacing the usual pagination with a Load More button. The approach behind this button is to render additional content to the page when clicked, without reloading the entire page.
<!-- partial_results.html -->
{% for title in page.object_list %}
<div class="result">
...
</div>
{% endfor %}
{% if page %}
<div id="load-more">
{% if page.has_next %}
<div class="load-more">
<button
hx-get="{% url 'partial_search' %}"
hx-target="#load-more"
hx-vals='{"q": "{{ request.GET.q }}", "page": "{{ page.next_page_number }}"}'
hx-swap="outerHTML"
>
Load More
</button>
</div>
{% endif %}
</div>
{% endif %}
hx-get
: sends a GET request to partial-search/
hx-target
: the <div>
with id="load-more"
which contains the Load More button.
For Load More button, we have 2 additional attributes we didn’t mention before:
hx-vals
: add parameters to be submitted within the request. It must be defined as a valid JSON (e.g. '{"page": "{{ page.next_page_number }}", "q": "{{ request.GET.q }}"}'
).
page
variable and the current search value q
as parameters to the GET
request.
hx-swap
: how the response will be swapped in relative to the target (hx-target
).
outerHTML
replaces the entire target element (<div id="load-more">β¦</div>
) with the response. There are many possible options for the value of this attribute.
YAY! π We did it! That’s how our partial search view with a Load More button comes about and it feels much better now!
This is just the beginning⦠There is so much more but the htmx docs are a great place to start and discover what else is possible. What I can say is: almost anything you want to do is feasible and there are multiple ways to accomplish that.
Some ideas from here:
π’ Now, tell meβ¦ Are you already using HTMX? What are the most interesting use-cases for which you have used Django with HTMX?