A 'No JS' Solution for Dynamic Search in Django

A pink bird holding a popcorn bucket at the cinema looking at the screen with a list of movies. The first movie on the list is the 'Django's Adventure'.
Image by Annie Ruygt

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.

Django’s Good Old Full Page Reload

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">
                &laquo; 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 }}">
                &raquo; Last
              </a>
          {% endif %}
      {% endif %}
  </div>
</section>

Now, let’s take the next steps and transform our search page!

HTMX to the Rescue! 🚀

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.

Partial Search View

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.
    • Issue a GET request to our partial-search/ url.
  • hx-target: specifies a target element for swapping - if not specified, it’s the element itself.
    • We define the <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.
    • HTMX supports multiple triggers separated by comma. Standard events can have modifiers, e.g. 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.

🪄 Load More

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 }}"}').
    • We send the next 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!

Discussion

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:

  • In our example, we are using a simple function based views but it can definitely work for class-based views. That’s a good challenge to start if you want to try it out.
  • Load More button is not necessary the best option for all list views. If you want to have the pagination and keep the dynamic functionality, it’s totally possible to do it. That’s something you can also try it out.

📢 Now, tell me… Are you already using HTMX? What are the most interesting use-cases for which you have used Django with HTMX?

Django really flies on Fly.io

You already know Django makes it easier to build better apps. Well now Fly.io makes it easier to _deploy_ those apps and move them closer to your users making it faster for them too!

Deploy a Django app today!