Plain is headed towards 1.0! Subscribe for development updates →

  1# plain.htmx
  2
  3**Integrate HTMX with templates and views.**
  4
  5- [Overview](#overview)
  6- [Template Fragments](#template-fragments)
  7    - [Lazy template fragments](#lazy-template-fragments)
  8    - [How does it work?](#how-does-it-work)
  9- [View Actions](#view-actions)
 10- [Dedicated Templates](#dedicated-templates)
 11- [Tailwind CSS variant](#tailwind-css-variant)
 12- [CSRF tokens](#csrf-tokens)
 13- [Error classes](#error-classes)
 14- [CSP](#csp)
 15- [Installation](#installation)
 16
 17## Overview
 18
 19The `plain-htmx` package adds a couple of unique features for working with HTMX.
 20One is [template fragments](#template-fragments) and the other is [view actions](#view-actions).
 21
 22The 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.
 23
 24The [`HTMXViewMixin`](./views.py#HTMXViewMixin) is the starting point for the server-side HTMX behavior.
 25To use these features on a view,
 26simply inherit from the class (yes, this is designed to work with class-based views).
 27
 28```python
 29from plain.views import TemplateView
 30
 31from plain.htmx.views import HTMXViewMixin
 32
 33
 34class HomeView(HTMXViewMixin, TemplateView):
 35    template_name = "home.html"
 36```
 37
 38In your `base.html` template (or wherever need the HTMX scripts),
 39you can use the `{% htmx_js %}` template tag:
 40
 41```html
 42<!-- base.template.html -->
 43{% load htmx %}
 44<!DOCTYPE html>
 45<html lang="en">
 46<head>
 47    <meta charset="UTF-8">
 48    <title>My Site</title>
 49    {% htmx_js %}
 50</head>
 51<body>
 52    {% block content %}{% endblock %}
 53</body>
 54```
 55
 56## Template Fragments
 57
 58An `{% htmxfragment %}` can be used to render a specific part of your template in HTMX responses.
 59When you use a fragment, all `hx-get`, `hx-post`, etc. elements inside that fragment will automatically send a request to the current URL,
 60render _only_ the updated content for the fragment,
 61and swap out the fragment.
 62
 63Here's an example:
 64
 65```html
 66<!-- home.html -->
 67{% extends "base.html" %}
 68
 69{% load htmx %}
 70
 71{% block content %}
 72<header>
 73  <h1>Page title</h1>
 74</header>
 75
 76<main>
 77  {% htmxfragment "main" %}
 78  <p>The time is {% now "jS F Y H:i" %}</p>
 79
 80  <button hx-get>Refresh</button>
 81  {% endhtmxfragment %}
 82</main>
 83{% endblock %}
 84```
 85
 86Everything inside `{% htmxfragment %}` will automatically update when "Refresh" is clicked.
 87
 88### Lazy template fragments
 89
 90If you want to render a fragment lazily,
 91you can add the `lazy` attribute to the `{% htmxfragment %}` tag.
 92
 93```html
 94{% htmxfragment "main" lazy=True %}
 95<!-- This content will be fetched with hx-get -->
 96{% endhtmxfragment %}
 97```
 98
 99This pairs nicely with passing a callable function or method as a context variable,
100which will only get invoked when the fragment actually gets rendered on the lazy load.
101
102```python
103def fetch_items():
104    import time
105    time.sleep(2)
106    return ["foo", "bar", "baz"]
107
108
109class HomeView(HTMXViewMixin, TemplateView):
110    def get_context(self, **kwargs):
111        context = super().get_context(**kwargs)
112        context["items"] = fetch_items  # Missing () are on purpose!
113        return context
114```
115
116```html
117{% htmxfragment "main" lazy=True %}
118<ul>
119  {% for item in items %}
120    <li>{{ item }}</li>
121  {% endfor %}
122</ul>
123{% endhtmxfragment %}
124```
125
126#### How does it work?
127
128When you use the `{% htmxfragment %}` tag,
129a standard `div` is output that looks like this:
130
131```html
132<div plain-hx-fragment="main" hx-swap="outerHTML" hx-target="this" hx-indicator="this">
133  {{ fragment_content }}
134</div>
135```
136
137The `plain-hx-fragment` is a custom attribute that we've added ("F" is for "Forge"),
138but the rest are standard HTMX attributes.
139
140When Plain renders the response to an HTMX request,
141it will get the `Plain-HX-Fragment` header,
142find the fragment with that name in the template,
143and render that for the response.
144
145Then the response content is automatically swapped in to replace the content of your `{% htmxfragment %}` tag.
146
147Note that there is no URL specified on the `hx-get` attribute.
148By default, HTMX will send the request to the current URL for the page.
149When you're working with fragments, this is typically the behavior you want!
150(You're on a page and want to selectively re-render a part of that page.)
151
152The `{% htmxfragment %}` tag is somewhat similar to a `{% block %}` tag --
153the fragments on a page should be named and unique,
154and you can't use it inside of loops.
155For fragment-like behavior inside of a for-loop,
156you'll most likely want to set up a dedicated URL that can handle a single instance of the looped items,
157and maybe leverage [dedicated templates](#dedicated-templates).
158
159## View Actions
160
161View actions let you define multiple "actions" on a class-based view.
162This is an alternative to defining specific API endpoints or form views to handle basic button interactions.
163
164With view actions you can design a single view that renders a single template,
165and associate buttons in that template with class methods in the view.
166
167As 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.
168
169In our template, we would use the `plain-hx-action` attribute to name the action:
170
171```html
172{% extends "base.html" %}
173
174{% load htmx %}
175
176{% block content %}
177<header>
178  <h1>{{ pullrequest }}</h1>
179</header>
180
181<main>
182  {% htmxfragment "pullrequest" %}
183  <p>State: {{ pullrequest.state }}</p>
184
185  {% if pullrequest.state == "open" %}
186    <!-- If it's open, they can close or merge it -->
187    <button hx-post plain-hx-action="close">Close</button>
188    <button hx-post plain-hx-action="merge">Merge</button>
189  {% else if pullrequest.state == "closed" %}
190    <!-- If it's closed, it can be re-opened -->
191    <button hx-post plain-hx-action="open">Open</button>
192  {% endif %}
193
194  {% endhtmxfragment %}
195</main>
196{% endblock %}
197```
198
199Then in the view class, we can define methods for each HTTP method + `plain-hx-action`:
200
201```python
202class PullRequestDetailView(HTMXViewMixin, DetailView):
203    def get_queryset(self):
204        # The queryset will apply to all actions on the view, so "permission" logic can be shared
205        return super().get_queryset().filter(users=self.user)
206
207    # Action handling methods follow this format:
208    # htmx_{method}_{action}
209    def htmx_post_open(self):
210        if self.object.state != "closed":
211            raise ValueError("Only a closed pull request can be opened")
212
213        self.object.state = "closed"
214        self.object.save()
215
216        # Render the updated content the standard calls
217        # (which will selectively render our fragment if applicable)
218        context = self.get_context(object=self.object)
219        return self.render_to_response(context)
220
221    def htmx_post_close(self):
222        if self.object.state != "open":
223            raise ValueError("Only a open pull request can be closed")
224
225        self.object.state = "open"
226        self.object.save()
227
228        context = self.get_context(object=self.object)
229        return self.render_to_response(context)
230
231    def htmx_post_merge(self):
232        if self.object.state != "open":
233            raise ValueError("Only a open pull request can be merged")
234
235        self.object.state = "merged"
236        self.object.save()
237
238        context = self.get_context(object=self.object)
239        return self.render_to_response(context)
240```
241
242This can be a matter of preference,
243but typically you may end up building out an entire form, API, or set of URLs to handle these behaviors.
244If you application is only going to handle these actions via HTMX,
245then a single View may be a simpler way to do it.
246
247Note that currently we don't have many helper-functions for parsing or returning HTMX responses --
248this can basically all be done through standard request and response headers:
249
250```python
251class PullRequestDetailView(HTMXViewMixin, DetailView):
252    def get_queryset(self):
253        # The queryset will apply to all actions on the view, so "permission" logic can be shared
254        return super().get_queryset().filter(users=self.user)
255
256    # You can also leave off the "plain-hx-action" attribute and just handle the HTTP method
257    def htmx_delete(self):
258        self.object.delete()
259
260        # Tell HTMX to do a client-side redirect when it receives the response
261        response = HttpResponse(status_code=204)
262        response.headers["HX-Redirect"] = "/"
263        return response
264```
265
266## Dedicated Templates
267
268A small additional features of `plain-htmx` is that it will automatically find templates named `{template_name}_htmx.html` for HTMX requests.
269More than anything, this is just a nice way to formalize a naming scheme for template "partials" dedicated to HTMX.
270
271Because template fragments don't work inside of loops,
272for example,
273you'll often need to define dedicated URLs to handle the HTMX behaviors for individual items in a loop.
274You can sometimes think of these as "pages within a page".
275
276So if you have a template that renders a collection of items,
277you can do the initial render using a Django `{% include %}`:
278
279```html
280<!-- pullrequests/pullrequest_list.html -->
281{% extends "base.html" %}
282
283{% block content %}
284
285{% for pullrequest in pullrequests %}
286<div>
287  {% include "pullrequests/pullrequest_detail_htmx.html" %}
288</div>
289{% endfor %}
290
291{% endblock %}
292```
293
294And then subsequent HTMX requests/actions on individual items can be handled by a separate URL/View:
295
296```html
297<!-- pullrequests/pullrequest_detail_htmx.html -->
298<div hx-url="{% url 'pullrequests:detail' pullrequest.uuid %}" hx-swap="outerHTML" hx-target="this">
299  <!-- Send all HTMX requests to a URL for single pull requests (works inside of a loop, or on a single detail page) -->
300  <h2>{{ pullrequest.title }}</h2>
301  <button hx-get>Refresh</button>
302  <button hx-post plain-hx-action="update">Update</button>
303</div>
304```
305
306_If_ you need a URL to render an individual item, you can simply include the same template fragment in most cases:
307
308```html
309<!-- pullrequests/pullrequest_detail.html -->
310{% extends "base.html" %}
311
312{% block content %}
313
314{% include "pullrequests/pullrequest_detail_htmx.html" %}
315
316{% endblock %}
317```
318
319```python
320# urls.py and views.py
321# urls.py
322default_namespace = "pullrequests"
323
324urlpatterns = [
325  path("<uuid:uuid>/", views.PullRequestDetailView, name="detail"),
326]
327
328# views.py
329class PullRequestDetailView(HTMXViewMixin, DetailView):
330  def htmx_post_update(self):
331      self.object.update()
332
333      context = self.get_context(object=self.object)
334      return self.render_to_response(context)
335```
336
337## Tailwind CSS variant
338
339The standard behavior for `{% htmxfragment %}` is to set `hx-indicator="this"` on the rendered element.
340This tells HTMX to add the `htmx-request` class to the fragment element when it is loading.
341
342Since Plain emphasizes using Tailwind CSS,
343here's a simple variant you can add to your `tailwind.config.js` to easily style the loading state:
344
345```js
346const plugin = require('tailwindcss/plugin')
347
348module.exports = {
349  plugins: [
350    // Add variants for htmx-request class for loading states
351    plugin(({addVariant}) => addVariant('htmx-request', ['&.htmx-request', '.htmx-request &']))
352  ],
353}
354```
355
356You can then prefix any class with `htmx-request:` to decide what it looks like while HTMX requests are being sent:
357
358```html
359<!-- The "htmx-request" class will be added to the <form> by default -->
360<form hx-post="{{ url }}">
361    <!-- Showing an element -->
362    <div class="hidden htmx-request:block">
363        Loading
364    </div>
365
366    <!-- Changing a button's class -->
367    <button class="text-white bg-black htmx-request:opacity-50 htmx-request:cursor-wait" type="submit">Submit</button>
368</form>
369```
370
371## CSRF tokens
372
373We configure CSRF tokens for you with the HTMX JS API.
374You don't have to put `hx-headers` on the `<body>` tag, for example.
375
376## Error classes
377
378This app also includes an HTMX extension for adding error classes for failed requests.
379
380- `htmx-error-response` for `htmx:responseError`
381- `htmx-error-response-{{ status_code }}` for `htmx:responseError`
382- `htmx-error-send` for `htmx:sendError`
383
384To enable them, use `hx-ext="plain-errors"`.
385
386You can add the ones you want as Tailwind variants and use them to show error messages.
387
388```js
389const plugin = require('tailwindcss/plugin')
390
391module.exports = {
392  plugins: [
393    // Add variants for htmx-request class for loading states
394    plugin(({addVariant}) => addVariant('htmx-error-response-429', ['&.htmx-error-response-429', '.htmx-error-response-429 &']))
395  ],
396}
397```
398
399## CSP
400
401```
402<meta name="htmx-config" content='{"includeIndicatorStyles":false}'>
403```
404
405## Installation
406
407Install the `plain.htmx` package from [PyPI](https://pypi.org/project/plain.htmx/):
408
409```bash
410uv add plain.htmx
411```
412
413Configure your Plain application:
414
415```python
416INSTALLED_PACKAGES = [
417    # ...
418    "plain.htmx",
419]
420```