Plain is headed towards 1.0! Subscribe for development updates →

HTMX

Integrate HTMX with templates and views.

The plain-htmx package adds a couple of unique features for working with HTMX. One is template fragments and the other is view actions.

The combination of these features lets you build HTMX-powered views that focus on server-side rendering and avoid overly complicated URL structures or REST APIs that you may not otherwise need.

The HTMXViewMixin is the starting point for the server-side HTMX behavior. To use these feaures on a view, simply inherit from the class (yes, this is designed to work with class-based views).

from plain.views import TemplateView

from plain.htmx.views import HTMXViewMixin


class HomeView(HTMXViewMixin, TemplateView):
    template_name = "home.html"

In your base.html template (or wherever need the HTMX scripts), you can use the {% htmx_js %} template tag:

<!-- base.template.html -->
{% load htmx %}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>My Site</title>
    {% htmx_js %}
</head>
<body>
    {% block content %}{% endblock %}
</body>

Installation

INSTALLED_PACKAGES = [
    # ...
    "plain.htmx",
]

Template Fragments

An {% htmxfragment %} can be used to render a specific part of your template in HTMX responses. When you use a fragment, all hx-get, hx-post, etc. elements inside that fragment will automatically send a request to the current URL, render only the updated content for the fragment, and swap out the fragment.

Here's an example:

<!-- home.html -->
{% extends "base.html" %}

{% load htmx %}

{% block content %}
<header>
  <h1>Page title</h1>
</header>

<main>
  {% htmxfragment main %}
  <p>The time is {% now "jS F Y H:i" %}</p>

  <button hx-get>Refresh</button>
  {% endhtmxfragment %}
</main>
{% endblock %}

Everything inside {% htmxfragment %} will automatically update when "Refresh" is clicked.

Lazy template fragments

If you want to render a fragment lazily, you can add the lazy attribute to the {% htmxfragment %} tag.

{% htmxfragment main lazy=True %}
<!-- This content will be fetched with hx-get -->
{% endhtmxfragment %}

This pairs nicely with passing a callable function or method as a context variable, which will only get invoked when the fragment actually gets rendered on the lazy load.

def fetch_items():
    import time
    time.sleep(2)
    return ["foo", "bar", "baz"]


class HomeView(HTMXViewMixin, TemplateView):
    def get_context(self, **kwargs):
        context = super().get_context(**kwargs)
        context["items"] = fetch_items  # Missing () are on purpose!
        return context
{% htmxfragment main lazy=True %}
<ul>
  {% for item in items %}
    <li>{{ item }}</li>
  {% endfor %}
</ul>
{% endhtmxfragment %}

How does it work?

When you use the {% htmxfragment %} tag, a standard div is output that looks like this:

<div plain-hx-fragment="main" hx-swap="outerHTML" hx-target="this" hx-indicator="this">
  {{ fragment_content }}
</div>

The plain-hx-fragment is a custom attribute that we've added ("F" is for "Forge"), but the rest are standard HTMX attributes.

When Plain renders the response to an HTMX request, it will get the Plain-HX-Fragment header, find the fragment with that name in the template, and render that for the response.

Then the response content is automatically swapped in to replace the content of your {% htmxfragment %} tag.

Note that there is no URL specified on the hx-get attribute. By default, HTMX will send the request to the current URL for the page. When you're working with fragments, this is typically the behavior you want! (You're on a page and want to selectively re-render a part of that page.)

The {% htmxfragment %} tag is somewhat similar to a {% block %} tag -- the fragments on a page should be named and unique, and you can't use it inside of loops. For fragment-like behavior inside of a for-loop, you'll most likely want to set up a dedicated URL that can handle a single instance of the looped items, and maybe leverage dedicated templates.

View Actions

View actions let you define multiple "actions" on a class-based view. This is an alternative to defining specific API endpoints or form views to handle basic button interactions.

With view actions you can design a single view that renders a single template, and associate buttons in that template with class methods in the view.

As an example, let's say we have a PullRequest model and we want users to be able to open, close, or merge it with a button.

In our template, we would use the plain-hx-action attribute to name the action:

{% extends "base.html" %}

{% load htmx %}

{% block content %}
<header>
  <h1>{{ pullrequest }}</h1>
</header>

<main>
  {% htmxfragment pullrequest %}
  <p>State: {{ pullrequest.state }}</p>

  {% if pullrequest.state == "open" %}
    <!-- If it's open, they can close or merge it -->
    <button hx-post plain-hx-action="close">Close</button>
    <button hx-post plain-hx-action="merge">Merge</button>
  {% else if pullrequest.state == "closed" %}
    <!-- If it's closed, it can be re-opened -->
    <button hx-post plain-hx-action="open">Open</button>
  {% endif %}

  {% endhtmxfragment %}
</main>
{% endblock %}

Then in the view class, we can define methods for each HTTP method + plain-hx-action:

class PullRequestDetailView(HTMXViewMixin, DetailView):
    def get_queryset(self):
        # The queryset will apply to all actions on the view, so "permission" logic can be shared
        return super().get_queryset().filter(users=self.request.user)

    # Action handling methods follow this format:
    # htmx_{method}_{action}
    def htmx_post_open(self):
        self.object = self.get_object()

        if self.object.state != "closed":
            raise ValueError("Only a closed pull request can be opened")

        self.object.state = "closed"
        self.object.save()

        # Render the updated content the standard calls
        # (which will selectively render our fragment if applicable)
        context = self.get_context(object=self.object)
        return self.render_to_response(context)

    def htmx_post_close(self):
        self.object = self.get_object()

        if self.object.state != "open":
            raise ValueError("Only a open pull request can be closed")

        self.object.state = "open"
        self.object.save()

        context = self.get_context(object=self.object)
        return self.render_to_response(context)

    def htmx_post_merge(self):
        self.object = self.get_object()

        if self.object.state != "open":
            raise ValueError("Only a open pull request can be merged")

        self.object.state = "merged"
        self.object.save()

        context = self.get_context(object=self.object)
        return self.render_to_response(context)

This can be a matter of preference, but typically you may end up building out an entire form, API, or set of URLs to handle these behaviors. If you application is only going to handle these actions via HTMX, then a single View may be a simpler way to do it.

Note that currently we don't have many helper-functions for parsing or returning HTMX responses -- this can basically all be done through standard request and response headers:

class PullRequestDetailView(HTMXViewMixin, DetailView):
    def get_queryset(self):
        # The queryset will apply to all actions on the view, so "permission" logic can be shared
        return super().get_queryset().filter(users=self.request.user)

    # You can also leave off the "plain-hx-action" attribute and just handle the HTTP method
    def htmx_delete(self):
        self.object = self.get_object()

        self.object.delete()

        # Tell HTMX to do a client-side redirect when it receives the response
        response = HttpResponse(status=204)
        response["HX-Redirect"] = "/"
        return response

Dedicated Templates

A small additional features of plain-htmx is that it will automatically find templates named {template_name}_htmx.html for HTMX requests. More than anything, this is just a nice way to formalize a naming scheme for template "partials" dedicated to HTMX.

Because template fragments don't work inside of loops, for example, you'll often need to define dedicated URLs to handle the HTMX behaviors for individual items in a loop. You can sometimes think of these as "pages within a page".

So if you have a template that renders a collection of items, you can do the initial render using a Django {% include %}:

<!-- pullrequests/pullrequest_list.html -->
{% extends "base.html" %}

{% block content %}

{% for pullrequest in pullrequests %}
<div>
  {% include "pullrequests/pullrequest_detail_htmx.html" %}
</div>
{% endfor %}

{% endblock %}

And then subsequent HTMX requests/actions on individual items can be handled by a separate URL/View:

<!-- pullrequests/pullrequest_detail_htmx.html -->
<div hx-url="{% url 'pullrequests:detail' pullrequest.uuid %}" hx-swap="outerHTML" hx-target="this">
  <!-- Send all HTMX requests to a URL for single pull requests (works inside of a loop, or on a single detail page) -->
  <h2>{{ pullrequest.title }}</h2>
  <button hx-get>Refresh</button>
  <button hx-post plain-hx-action="update">Update</button>
</div>

If you need a URL to render an individual item, you can simply include the same template fragment in most cases:

<!-- pullrequests/pullrequest_detail.html -->
{% extends "base.html" %}

{% block content %}

{% include "pullrequests/pullrequest_detail_htmx.html" %}

{% endblock %}
# urls.py and views.py
# urls.py
default_namespace = "pullrequests"

urlpatterns = [
  path("<uuid:uuid>/", views.PullRequestDetailView, name="detail"),
]

# views.py
class PullRequestDetailView(HTMXViewMixin, DetailView):
  def htmx_post_update(self):
      self.object = self.get_object()

      self.object.update()

      context = self.get_context(object=self.object)
      return self.render_to_response(context)

Tailwind CSS variant

The standard behavior for {% htmxfragment %} is to set hx-indicator="this" on the rendered element. This tells HTMX to add the htmx-request class to the fragment element when it is loading.

Since Plain emphasizes using Tailwind CSS, here's a simple variant you can add to your tailwind.config.js to easily style the loading state:

const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    // Add variants for htmx-request class for loading states
    plugin(({addVariant}) => addVariant('htmx-request', ['&.htmx-request', '.htmx-request &']))
  ],
}

You can then prefix any class with htmx-request: to decide what it looks like while HTMX requests are being sent:

<!-- The "htmx-request" class will be added to the <form> by default -->
<form hx-post="{{ url }}">
    <!-- Showing an element -->
    <div class="hidden htmx-request:block">
        Loading
    </div>

    <!-- Changing a button's class -->
    <button class="text-white bg-black htmx-request:opacity-50 htmx-request:cursor-wait" type="submit">Submit</button>
</form>

CSRF tokens

We configure CSRF tokens for you with the HTMX JS API. You don't have to put hx-headers on the <body> tag, for example.

Error classes

This app also includes an HTMX extension for adding error classes for failed requests.

  • htmx-error-response for htmx:responseError
  • htmx-error-response-{{ status_code }} for htmx:responseError
  • htmx-error-send for htmx:sendError

To enable them, use hx-ext="error-classes".

You can add the ones you want as Tailwind variants and use them to show error messages.

const plugin = require('tailwindcss/plugin')

module.exports = {
  plugins: [
    // Add variants for htmx-request class for loading states
    plugin(({addVariant}) => addVariant('htmx-error-response-429', ['&.htmx-error-response-429', '.htmx-error-response-429 &']))
  ],
}

CSP

<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
  1import jinja2
  2from jinja2 import meta, nodes
  3from jinja2.ext import Extension
  4
  5from plain.runtime import settings
  6from plain.templates import register_template_extension
  7from plain.templates.jinja.extensions import InclusionTagExtension
  8
  9
 10@register_template_extension
 11class HTMXJSExtension(InclusionTagExtension):
 12    tags = {"htmx_js"}
 13    template_name = "htmx/js.html"
 14
 15    def get_context(self, context, *args, **kwargs):
 16        return {
 17            "csrf_token": context["csrf_token"],
 18            "DEBUG": settings.DEBUG,
 19            "extensions": kwargs.get("extensions", []),
 20        }
 21
 22
 23@register_template_extension
 24class HTMXFragmentExtension(Extension):
 25    tags = {"htmxfragment"}
 26
 27    def __init__(self, environment):
 28        super().__init__(environment)
 29        environment.extend(htmx_fragment_nodes={})
 30
 31    def parse(self, parser):
 32        lineno = next(parser.stream).lineno
 33
 34        fragment_name = parser.parse_expression()
 35
 36        kwargs = []
 37
 38        while parser.stream.current.type != "block_end":
 39            if parser.stream.current.type == "name":
 40                key = parser.stream.current.value
 41                parser.stream.skip()
 42                parser.stream.expect("assign")
 43                value = parser.parse_expression()
 44                kwargs.append(nodes.Keyword(key, value))
 45
 46        body = parser.parse_statements(["name:endhtmxfragment"], drop_needle=True)
 47
 48        call = self.call_method(
 49            "_render_htmx_fragment",
 50            args=[fragment_name, jinja2.nodes.ContextReference()],
 51            kwargs=kwargs,
 52        )
 53
 54        node = jinja2.nodes.CallBlock(call, [], [], body).set_lineno(lineno)
 55
 56        # Store a reference to the node for later
 57        self.environment.htmx_fragment_nodes.setdefault(parser.name, {})[
 58            fragment_name.value
 59        ] = node
 60
 61        return node
 62
 63    def _render_htmx_fragment(self, fragment_name, context, caller, **kwargs):
 64        def attrs_to_str(attrs):
 65            parts = []
 66            for k, v in attrs.items():
 67                if v == "":
 68                    parts.append(k)
 69                else:
 70                    parts.append(f'{k}="{v}"')
 71            return " ".join(parts)
 72
 73        render_lazy = kwargs.get("lazy", False)
 74        as_element = kwargs.get("as", "div")
 75        attrs = {}
 76        for k, v in kwargs.items():
 77            if k.startswith("hx_"):
 78                attrs[k.replace("_", "-")] = v
 79            else:
 80                attrs[k] = v
 81
 82        if render_lazy:
 83            attrs.setdefault("hx-swap", "outerHTML")
 84            attrs.setdefault("hx-target", "this")
 85            attrs.setdefault("hx-indicator", "this")
 86            attrs_str = attrs_to_str(attrs)
 87            return f'<{as_element} plain-hx-fragment="{fragment_name}" hx-get hx-trigger="plainhtmx:load from:body" {attrs_str}></{as_element}>'
 88        else:
 89            # Swap innerHTML so we can re-run hx calls inside the fragment automatically
 90            # (render_template_fragment won't render this part of the node again, just the inner nodes)
 91            attrs.setdefault("hx-swap", "innerHTML")
 92            attrs.setdefault("hx-target", "this")
 93            attrs.setdefault("hx-indicator", "this")
 94            # Add an id that you can use to target the fragment from outside the fragment
 95            attrs.setdefault("id", f"plain-hx-fragment-{fragment_name}")
 96            attrs_str = attrs_to_str(attrs)
 97            return f'<{as_element} plain-hx-fragment="{fragment_name}" {attrs_str}>{caller()}</{as_element}>'
 98
 99
100def render_template_fragment(*, template, fragment_name, context):
101    template = find_template_fragment(template, fragment_name)
102    return template.render(context)
103
104
105def find_template_fragment(template: jinja2.Template, fragment_name: str):
106    # Look in this template for the fragment
107    callblock_node = template.environment.htmx_fragment_nodes.get(
108        template.name, {}
109    ).get(fragment_name)
110
111    if not callblock_node:
112        # Look in other templates for this fragment
113        matching_callblock_nodes = []
114        for fragments in template.environment.htmx_fragment_nodes.values():
115            if fragment_name in fragments:
116                matching_callblock_nodes.append(fragments[fragment_name])
117
118        if len(matching_callblock_nodes) == 0:
119            # If we still haven't found anything, it's possible that we're
120            # in a different/new worker/process and haven't parsed the related templates yet
121            ast = template.environment.parse(
122                template.environment.loader.get_source(
123                    template.environment, template.name
124                )[0]
125            )
126            for ref in meta.find_referenced_templates(ast):
127                if ref not in template.environment.htmx_fragment_nodes:
128                    # Trigger them to parse
129                    template.environment.get_template(ref)
130
131            # Now look again
132            for fragments in template.environment.htmx_fragment_nodes.values():
133                if fragment_name in fragments:
134                    matching_callblock_nodes.append(fragments[fragment_name])
135
136        if len(matching_callblock_nodes) == 1:
137            callblock_node = matching_callblock_nodes[0]
138        elif len(matching_callblock_nodes) > 1:
139            raise jinja2.TemplateNotFound(
140                f"Fragment {fragment_name} found in multiple templates. Use a more specific name."
141            )
142        else:
143            raise jinja2.TemplateNotFound(
144                f"Fragment {fragment_name} not found in any templates"
145            )
146
147    if not callblock_node:
148        raise jinja2.TemplateNotFound(
149            f"Fragment {fragment_name} not found in template {template.name}"
150        )
151
152    # Create a new template from the node
153    template_node = jinja2.nodes.Template(callblock_node.body)
154    return template.environment.from_string(template_node)