Summary
We have a to-do list app, it's simple and it works. It even has some Ajax magic to not reload the whole page.
But we still have to push an update button when we want to mark tasks as done.
We'll get rid of that, by using HTMX to trigger Ajax requests on specific elements and events.
And while we are at it, we will use correct HTTP verbs instead of just GET
and POST
.
Yes, you got that right, we will remove a whole button and change keywords. Be prepared.
Removing the ugly button
In the previous article we had a primer on how to create a to-do list without HTMX, then a little glance at it from afar using hx-boost
.
But because of the limitations of the Web browser, we had to have a big "update" button to change the "done"/"not done" status of all tasks. As a reminder, here is what the template used to look like (colored version here):
<!DOCTYPE html>
<html>
<head>
<title>Todo List</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<style>
li {
list-style-type: none !important;
}
input:checked + label {
text-decoration: line-through;
}
</style>
</head>
<body>
<div class="container" >
<br>
<!-- Sprinkle hx-boost="true" on all the things we want to be dynamic -->
<h1>Todo <span>({{counts['done']}}/{{counts['total']}})</span></h1>
<form action="/add" 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>
<form action="/set_task_status" method="post" hx-boost="true" >
<ul>
% for task in tasks:
<li >
<input type="checkbox" id="task{{task['id']}}"
name="task"
value="{{task['id']}}"
% if task['done']:
checked
% end
>
<label for="task{{task['id']}}">{{task['title']}}</label>
<a href="/delete/{{task['id']}}" hx-boost="true" >X</a>
</li>
% end
</ul>
% if tasks:
<button type="submit" class="button-primary">Update</button>
% end
</form>
</div>
</body>
</html>
That's not a great experience in 2023 where you can just Ajax fetch()
on a click on any checkbox.
And that's exactly what we are going to do, but using HTMX. Which means instead of writing JS code to setup the event on click, setup a request call with a callback and resolve the promise, we will... write a bunch of HTML attributes.
It will do the same, mind you. Only declaratively. And we will exchange HTML instead of JSON.
Let's touch only the <form>
for now. First, we remove the button and hx-boost="true"
:
<form action="/set_task_status" method="post" >
<ul>
% for task in tasks:
<li >
<input type="checkbox" id="task{{task['id']}}"
name="task"
value="{{task['id']}}"
% if task['done']:
checked
% end
>
<label for="task{{task['id']}}">{{task['title']}}</label>
<a href="/delete/{{task['id']}}" >X</a>
</li>
% end
</ul>
</form>
That makes the form useless, but it's now clean to add other things.
Let’s start by telling HTMX when to do its magic. We want the form to send a request to update the status of the tasks when they change. Conveniently, there is a standard change
event on the form that detects exactly that.
In vanilla JS, we would do something like form.addEventListener("change")
, but with HTMX we use hx-trigger
:
<form hx-trigger="change" action="/set_task_status" method="post" >
...
</form>
Then we replace action
and method
with hx-post
:
<form hx-trigger="change" hx-post="/set_task_status" >
...
</form>
This tells HTMX "when any value of any input in the form changes, do a POST request to /set_task_status
.
Then we have to choose what to do with the response.
By default HTMX takes the content of <body>
in the response and inject it in the current page. But it places it in the element that sent the request, here our <form>
.
Because our endpoint on /set_task_status
redirects to the index, which renders a full page, that's not what we need. It would insert a full page inside our form, which would give you this:
We can tell HTMX to insert the content inside <body>
with hx-target
:
<form hx-trigger="change" hx-post="/set_task_status" hx-target="body">
...
</form>
So “when values change, POST to /set_task_status, extract the content of the body from the response, put the result in the body of the page”.
To be fair, that’s what `hx-boost=true` used to do, we simply do it more explicitly.
Now the entire templates looks like this (colored version here):
<!DOCTYPE html>
<html>
<head>
<title>Todo List</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<style>
li {
list-style-type: none !important;
}
input:checked + label {
text-decoration: line-through;
}
</style>
</head>
<body>
<div class="container">
<br>
<h1>Todo <span>({{counts['done']}}/{{counts['total']}})</span></h1>
<form action="/add" 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>
<form
hx-trigger="change"
hx-post="/set_task_status"
hx-target="body"
>
<ul>
% for task in tasks:
<li >
<input type="checkbox" id="task{{task['id']}}"
name="task"
value="{{task['id']}}"
% if task['done']:
checked
% end
>
<label for="task{{task['id']}}">{{task['title']}}</label>
<a href="/delete/{{task['id']}}" hx-boost="true" >X</a>
</li>
% end
</ul>
</form>
</div>
</body>
</html>
You'll notice we haven't changed anything else. The server code is the same. And most of the template is the same. Firstly, because the HTTP requests haven't changed. And secondly because all features of HTMX work well together since they are triggered by HTML attributes.
After all, what do you get when you perform an HTMX request? A response with HTML. With attributes. Ready to do more requests.
This means the other hx-boost="true"
still work, even the one from inside the form. There is nothing to do.
If you want to try this new version, clone the no-update-button tag, or download the zip and run server.py
from inside the directory "3_no_update_button".
Better HTTP verbs
As we discussed before, browsers without JS are restricted to GET and POST requests. It's limiting, because we have more HTTP verbs than this: DELETE
, PUT
, PATCH
...
GET
is supposed to express something you read, something that will have no side effect, and in our code right now, we issue a task deletion with it!
You might think this is pedantic. After all, it works, so who cares what verb we use?
There is some truth to that, but it's not the full story.
A good part of the Web works assuming those verbs have certain semantics. Well-behaved web crawlers like GoogleBot will issue GET
requests, but not DELETE
, because they assume those are safe. If a bot crawled our to-do list in its current state (OK, it should be behind a login page, but bear with me), it could delete all tasks.
Caching mechanisms also assume that GET
can be cached by default, but not DELETE
. Plus not all HTTP methods are verbs. We also have HEAD
and OPTION
that have very narrow meaning.
Even without all this, for your sanity and code clarity, using proper HTTP verbs will mark your endpoints with a label that instantly distinguishes it from others. It will make your system easier to understand. And easier to split: the same endpoint for the same URL can now be divided into several verbs with different behaviors.
So let's be good Web citizens, and replace our deleting link GET
request with a DELETE
. Before we had:
<a href="/delete/{{task['id']}}" hx-boost="true">X</a>
Now, we will have this:
<a hx-delete="/delete/{{task['id']}}" hx-target="body">X</a>
hx-delete
tells HTMX to issue a DELETE
request to the URL where the href
used to point us to. hx-target="body"
is once again used to say, "insert the response into our <body>
".
Remember, our server delete the tasks, then redirects us to the index, which renders a full page. And HTMX, by default, take the <body>
content of the response and swap it with the current element. We don't want the full page to be inserted in the link, so we tell HTMX to insert it into our current <body>
.
Maybe you are wondering why we don't use hx-triggers="click"
to tell HTMX to make the request only when we click on the link. After all, we added hx-triggers="change"
on the <form>
.
That's because submit
is the default event for the <form> element
, so we had to specify we wanted another one. But click
is the default event for links. We don't need to specify it.
Finally, we have to change our code in server.py
.
The endpoint to delete a task use to be:
@route("/delete/<task_id:int>", method="GET")
def delete(task_id):
todo.delete_todo(task_id)
redirect("/")
Now we will change the method
parameter, so that it becomes:
@route("/delete/<task_id:int>", method="DELETE")
def delete(task_id):
todo.delete_todo(task_id)
redirect("/")
This clearly marks our endpoint as something that accepts deletion requests from the client. In fact, our framework, bottle, will now return an error message if anything else than DELETE is used.
Once more, with feeling
We actually have 3 user actions in the projects that have a side effect:
Creating a task.
Deleting a task.
Updating a task.
We took care of deletion with a proper DELETE
request. And there is nothing to do for creating, POST
is indeed the correct verb for this, and it's what our task creation form does.
But we can now make amend for updating the task with the wrong verb.
Let's change the task status form once again. It used to be:
<form
hx-trigger="change"
hx-post="/set_task_status"
hx-target="body"
>
Now we can use hx-put
instead of hx-post
:
<form
hx-trigger="change"
hx-put="/set_task_status"
hx-target="body"
>
As you expect, this will issue a PUT
request, which means we have to change our server code for updating tasks as well. From:
@route("/set_task_status", method="POST")
def set_task_status():
...
To using PUT
in the method
:
@route("/set_task_status", method="PUT")
def set_task_status():
...
Small detail, I know.
Everything together
The final template looks like this (colored version here):
<!DOCTYPE html>
<html>
<head>
<title>Todo List</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<style>
li {
list-style-type: none !important;
}
input:checked + label {
text-decoration: line-through;
}
</style>
</head>
<body>
<div class="container">
<br>
<h1>Todo <span>({{counts['done']}}/{{counts['total']}})</span></h1>
<form action="/add" 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>
<form
hx-trigger="change"
hx-put="/set_task_status"
hx-target="body"
>
<ul>
% for task in tasks:
<li >
<input type="checkbox" id="task{{task['id']}}"
name="task"
value="{{task['id']}}"
% if task['done']:
checked
% end
>
<label for="task{{task['id']}}">{{task['title']}}</label>
<a
hx-delete="/delete/{{task['id']}}"
hx-target="body"
>X</a>
</li>
% end
</ul>
</form>
</div>
</body>
</html>
And the final servers.py like this (colored version here):
import sys
from pathlib import Path
### BOILERPLATE TO MINIMIZE DEMO SETUP FOR YOU, IGNORE ###
CUR_DIR = Path(__file__).resolve().parent
sys.path.append(str(CUR_DIR.parent))
import todo
from bottle import TEMPLATE_PATH, debug, redirect, request, route, run, template
TEMPLATE_PATH.append(str(CUR_DIR))
### END BOILER PLATE ###
@route("/")
def index():
tasks = todo.list_todos()
counts = todo.count_todos()
return template("index", tasks=tasks, counts=counts)
@route("/add", method="POST")
def add():
title = request.forms.get("title")
if title:
todo.add_todo(title)
redirect("/")
@route("/set_task_status", method="PUT")
def set_task_status():
all_tasks = todo.list_todos()
done_task_ids = set(int(task_id) for task_id in request.forms.getall("task"))
for task in all_tasks:
new_status = task["id"] in done_task_ids
todo.set_task_status(task["id"], new_status)
redirect("/")
@route("/delete/<task_id:int>", method="DELETE")
def delete(task_id):
todo.delete_todo(task_id)
redirect("/")
if __name__ == "__main__":
debug(True)
run(host="localhost", port=8080, reloader=True)
To try it, clone the better-http-verbs tag, or download the zip and run server.py
from inside the directory "4_better_http_verbs".
At this stage, we removed the update button and started to use correct HTTP verbs. In the next article, we are going to:
clean up our URLs so they look like resources instead of actions (a bit more pedantry, I must confess);
start splitting our template;
issue more granular requests instead of updating the whole page every time.