In this part 4, we will fix the issues identified in part 3:
the task counter not updating;
the task addition reloading the whole page;
and some checkboxes being set for no good reason.
For this we will use the usual suspects, hx-trigger
, hx-post
, hx-target
and hx-swap
.
With a few twists:
We'll set autocomplete to off on checkboxes.
We'll use
hx-swap
withbeforeend
to append to the list of tasks.We'll send an event from the server!
We'll listen to one event with
hx-on
and nothx-trigger
to use... JS code.
A little bit more to go
The third article introduced bugs, the most annoying being that the task counter doesn't reflect our real task numbers anymore, since we are not refreshing the task list separately.
Also, adding a new task still reloads the whole body, which does refresh the counter.
Finally, if a user refreshes the page manually, some checkboxes are ticked automatically by the browser itself.
That's a lot of annoying details we have to iron out.
Autocompletion, the blessing and curse of our era
You know when you send "duck" in a text message, and you almost never, ever meant duck?
Yet a lot of people have to duck off. I regularly duck my life. And acceptance leads to duck it.
At least in chat apps messages.
It's the same in the web browsers, but for all the widgets.
Our little client wants to help, and autocomplete every single input, including checkboxes, which is why when you refresh our page some of them are ticked when they should not be.
A problem you don't have with Vue, React or Svelte, since they re-render the component when the state change, forcing the value.
To solve that with HTMX, we have to turn off autocomplete manually. Our checkbox input was:
<input type="checkbox"
name="task"
id="task{{task['id']}}"
% if task['done']:
checked
% end
hx-trigger="change"
hx-put="/tasks/{{task['id']}}"
hx-swap="outerHTML"
hx-target="closest li"
>
Now becomes:
<input type="checkbox"
name="task"
id="task{{task['id']}}"
% if task['done']:
checked
% end
hx-trigger="change"
hx-put="/tasks/{{task['id']}}"
hx-swap="outerHTML"
hx-target="closest li"
autocomplete="off"
>
OK, this part was easy.
Appending one task at a time
Now let's figure out how to avoid reloading the entire page when adding a task.
We start with the server side python code, which used to be:
@route("/tasks/", method="POST")
def add():
title = request.forms.get("title")
if title:
todo.add_todo(title)
redirect("/")
This adds the task, and redirects to the index. We don't need the redirection anymore. But we do need it to return the HTML for the task added, so it becomes:
@route("/tasks/", method="POST")
def add():
title = request.forms.get("title")
if title:
new_task = todo.add_todo(title)
return template("_task", task=new_task)
return ""
Having the task HTML extracted into a separate _task.tpl
template proved handy once again.
We can now change our form to tell it to POST to /tasks
, but this time, insert the response at the end of the list:
Our form used to be:
<form action="/tasks/" method="post" hx-boost="true" >
<input type="text" name="title" placeholder="New task" value="" autocomplete="off">
<button type="submit" value="Add" class="button-primary" role="button" >Add</button>
</form>
(Just noticed it had autocomplete set to off as well, I added it mechanically at the beginning of the tutorial :))
We remove the magic of hx-boost="true"
to get a little more hands on, and the next form is now:
<form
hx-trigger="submit"
hx-post="/tasks/"
hx-target="ul"
hx-swap="beforeend"
>
<input type="text" name="title" placeholder="New task" value="" autocomplete="off">
<button type="submit" value="Add" class="button-primary" role="button" >Add</button>
</form>
hx-trigger="submit"
and hx-post="/tasks/"
tell HTMX to send a POST request on /tasks/
then the "submit" event arises on the form.
hx-target
says where to insert the response, which is our new task HTML. We want it in the list of tasks, so we tell HTMX to insert it in the ul
element. Remember that hx-target
accepts any CSS selector. Here, the CSS selector is simply ul
because our example is so basic it contains only one ul
element, no need for an id or a class.
Then we can tell HTMX how to insert the response in the target, by using hx-swap
. beforeend
means to insert it inside the ul
, just before the end of it. So as the last task of our list.
A little scripting
All this will work, but we have a new problem: when typing a new task and adding it, it's added to the list. But the text remains in the original input. When we reloaded the entire page, we didn't have this issue, because the input always started fresh and empty.
But the new situation would force the user to clear it manually for every new task to add. It's not practical.
We are going to clear it when a task is added, and we will do this using...
Javascript.
Yes, if you heard that HTMX is meant to avoid using JS, that will surprise you. But HTMX is mostly about using hypermedia as the basis of your app. It's not an anti-js tool.
In fact, HTMX comes with hooks to execute JS code, and has functions to allow JS code to react to HTMX life cycle. It's fundamentally well integrated with JS. After all, it is coded in JS.
One of those hooks is hx-on
. While hx-trigger
reacts on events to trigger a HTTP request, hx-on
is a more generic version that will execute any arbitrary Javascript code.
E.G: <div hx-on:click="console.log('hello')" >Hello</div>
will log to the console "hello" when you click on the div.
Now you are going to tell me, why not use onclick
? It's native to the browser after all. It's really the same, but hx-on
also can listen to HTMX events, which is what we want for what happens next.
Clearing the input, remember?
We will react to an event called htmx:after-request
, which is an event HTMX emits, as you guessed, after it sends an HTTP request. The JS code will call the reset()
method of the form element, which clears all fields inside:
<form
hx-trigger="submit"
hx-post="/tasks/"
hx-target="ul"
hx-swap="beforeend"
hx-on:htmx:after-request="this.reset()"
>
<input type="text" name="title" placeholder="New task" value="" autocomplete="off">
<button type="submit" value="Add" class="button-primary" role="button" >Add</button>
</form>
Problem solved. The full HTML template.
You can also try by cloning the appending-new-task tag, or download the zip and run server.py
from inside the directory "8_appending_a_new_task".
Events all way down
Now you know you can react to events, to trigger HTTP requests, which will trigger events on which you can react to run some JS.
Now for the fun thing: when you get a response, it also triggers events. And some of those events can come from the server itself. And HTMX can react on that. To trigger more requests.
You see where this is going?
HTMX allows you to create a full event-driven system, and we will use that feature to fix our counter bug.
The problem was that our task counter was not updating, because we now only perform partial page updates.
The solution is to trigger an event when a task is added, deleted or updated, so that our counter refreshes.
We can do that from the server!
Every time we send a response from an endpoint that does one of those operations, we can add a HX-Trigger
header to it. Let's name the event update-counter
and use bottle .set_header()
method to do exactly this in server.py:
@route("/tasks/", method="POST")
def add():
title = request.forms.get("title")
if title:
new_task = todo.add_todo(title)
response.set_header("HX-Trigger", "update-counter")
return template("_task", task=new_task)
return ""
@route("/tasks/<task_id:int>", method="PUT")
def toggle_task(task_id):
status = request.forms.get("task")
todo.set_task_status(task_id, bool(status))
response.set_header("HX-Trigger", "update-counter")
return template("_task", task=todo.get_task(task_id))
@route("/tasks/<task_id:int>", method="DELETE")
def delete(task_id):
todo.delete_todo(task_id)
response.set_header("HX-Trigger", "update-counter")
return ""
We also need a new endpoint to return the new counter values:
@route("/tasks/count")
def counters():
counts = todo.count_todos()
return "(%(done)s/%(total)s)" % counts
I don't even make a template for this one, because it's so small.
You can see the full server.py file here.
The last detail to take care of is to tell HTMX to make a GET request to "/tasks/count" when the event update-counter
occurs.
We replace our old h1
in the index template:
<h1>Todo <span>({{counts['done']}}/{{counts['total']}})</span></h1>
With a new one that self udpates:
<h1>Todo <span
hx-trigger="update-counter from:body"
hx-get="/tasks/count"
>({{counts['done']}}/{{counts['total']}})</span></h1>
The counts
first comes from the index page template on first. But the span sets hx-trigger
to react to update-counter
. When this event happens, it sends a GET request to /tasks/count
. By default, HTMX replaces the content inside the current element with the response, so for once we don't need hx-swap
.
The only surprise here is from:body
. It's one of the modifiers you can apply to event handling in HTMX. There are many of them so apply delay, queue events or expect Ctrl to be held. from:<selector>
tell HTMX to not listen for the event on the current element, but on one matching the CSS selector.
In this particular case, we tell HTMX to listen to any update-counter
on the <body>
element. Because update-counter
can come from many sources on update, insert or delete, the body of the page is the only element that is certain to get them all as events bubble all the way to the top in all web browsers unless you stop them.
And voilà.
From now on, every time the user adds a task, deletes a task or marks one as "done", it will trigger the update of the counter.
You can try it by cloning the updating-the-counter tag, or download the zip and run server.py
from inside the directory "9_updating_the_counter".
This could be the end
You don't need anything more to be productive with HTMX, but we will add a bonus article soon, where we will add some bling with animations and talk a little bit more about integrating it with JS.
Next article will not be about HTMX, though, since we are the 29th, and the blog does a press review on what happens in the Python community at every end of the month.