Summary
The to-do works, so we are going to break it. Because that's what devs do, they touch working code to make it better and introduce bugs in the process.
Before we do, we will actually improve it a little by:
Setting resource oriented URLs so that Tim Berners-Lee doesn't curse us.
Extracting the task HTML into a separate template.
Simplifying our update and deletion endpoints.
Telling HTMX to update and delete tasks without reloading the whole body.
If you are longing for code, you can jump into the full source right now, but the article will introduce this step by step.
Where to now?
The second article made our to-do more dynamic, but we are still essentially rendering and loading the entire page every time we perform any update.
It's not as bad as it sounds. That's pretty much how all web sites worked two decades ago, and they had way less powerful machines. We could call it a day and say it's good enough. After all, from a user’s perspective, they don't know that, the app works.
However, updating only parts of the page is not just going to make it eat fewer resources, it will also have other benefits:
Less data to compute, to go through the network, and to render, means the experience will feel snappier.
It will work fine on less fancy internet connections.
The user focus or selection will be preserved after updates.
We can isolate expensive paths and call them less often or cache them.
The user can use one part of the view-port while another one is loading.
So let's make those updates more granular.
Before doing this, though, I will add a tad of nit-picking: our URLs naming can be improved.
The HTTP protocol is born resources oriented. The spec expects you to use methods like the GET, POST, DELETE, etc. verbs to express the actions you want to perform, and the URLs on what you want to perform each action on.
URL means, after all, Universal Resource Locator. It's supposed to tell you what, not how.
I say "supposed" because there is nothing forcing us to do so. Unlike the HTTP verbs that trigger some behaviors in certain systems, URL will probably not.
Before we could do Ajax requests, we had to send stuff like "POST /delete_task/" because "DELETE" was seldom available in the browser anyway.
Then for some time it became popular to have beautiful URLs because it affected SEO. Today, much less, not to mention the Web has become less about sites and more about apps, which don't necessarily map well to a Create/Read/Update/Delete sets of actions.
Still, I think using URLs as they were intended has some nice consequences:
It will incite the devs to think in HTTP, instead of using a generic RPC model. The whole pipeline between the server and the client is more cooperative when you do.
URLs become self-documenting and can even describe how your endpoints are organized.
URLs become a part of the UI.
Granted, you can forgo those, and even say that breaking the rules allow you to make some interesting workflow improvements.
I'm not saying you have to do this, but since it usually makes sense in the context of an HTMX stack, let's go for it.
Resource oriented URLS
We have paths in our URLs like:
/set_task_status
/add
/detele/<task_i>
Those strings contain the actions we want to perform. But the actions are already described by our HTTP verbs, what we need now is to have URL paths to describe on what those actions are performed.
The typical way to do this is:
"/resources/" to address all resources
"/resources/<id>" to address one specific resource
"/category/resource/qualifier?filter=value" if you want get more control over the hierarchy or what's displayed.
For our app, we have a single resource, the task, so our URLs can become:
"/tasks/" to apply something to all the tasks.
"/tasks/<id>" to apply something to a particular task.
In combination with our verbs, we can then:
"GET /tasks/<id>" to read a task.
"DELETE /tasks/<id>" to delete a task.
"PUT /tasks/<id>" to update a single task.
"POST /tasks/" to add a task to the list.
"GET /tasks/" list all tasks.
"PUT /tasks/" to update all the tasks.
etc.
This change is not going to affect the HTMX part of our code in any way. We just have to update our template and backend code to reflect the new routes.
First server.py
(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("/tasks/", method="POST")
def add():
title = request.forms.get("title")
if title:
todo.add_todo(title)
redirect("/")
@route("/tasks/", 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("/tasks/<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)
Then the template (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="/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>
<form
hx-trigger="change"
hx-put="/tasks/"
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="/tasks/{{task['id']}}"
hx-target="body"
>X</a>
</li>
% end
</ul>
</form>
</div>
</body>
</html>
I'm cheating a little bit here, because technically, we should issue a "PATCH" verb and not a "PUT" verb to update those tasks status, but I've been pedantic enough for the month.
We are ready to move to more practical concerns.
A separate task template
Since we want to update things more granularly, it would be wise to split the program into smaller parts to make that easier.
In a typical SPA with a JSON API, we would have a task dedicated endpoint, with a way to get only the information about a specific task.
In a hypermedia application (the stuff we are building), it's the same, except we don't return JSON, we return HTML.
To do this, we first need to have a task representation that is independent of the whole page.
Fortunately, the lib we are using, bottle, allows us to split templates in chunks and insert them where we want, so we can create one with just the HTML for a single task:
<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="/tasks/{{task['id']}}"
hx-target="body"
>X</a>
</li>
We put this in a "_task.tpl" file, then we include that template inside our main one (colored version):
<!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="/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>
<form
hx-trigger="change"
hx-put="/tasks/"
hx-target="body"
>
<ul>
% for task in tasks:
% include('_task.tpl', task=task)
% end
</ul>
</form>
</div>
</body>
</html>
At that point it doesn't change anything to our app. The logic stays the same, the network calls stay the same. We only made preparations for the next part.
More granular task updates
Up to now, we have been settings the "done" / "not done" status on all tasks every time. We can modify our endpoint in server.py to only do it for a single task and go from this loop:
@route("/tasks/", 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("/")
To this:
@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))
return template("_task", task=todo.get_task(task_id))
Much simpler, more performant, and it uses our _task
template that contains only the representation of one task.
While we are at it, we can take our old delete endpoint:
@route("/tasks/<task_id:int>", method="DELETE")
def delete(task_id):
todo.delete_todo(task_id)
redirect("/")
And remove the redirection to the index:
@route("/tasks/<task_id:int>", method="DELETE")
def delete(task_id):
todo.delete_todo(task_id)
return ""
The final file looks like this.
As usual, it's ok if you don't get all the Python code. The gist of it is that we won't process everything anymore.
The HTMX code is more interesting.
Remember our link to delete the task? It used to swap the entire existing body with the one from the response:
<a hx-delete="/tasks/{{task['id']}}"
hx-target="body"
>X</a>
But we want granular updates, so we will use:
hx-target to tell HTMX where to insert the response. Hint: not just “body”.
hx-swap to say how to insert the response.
Let's use hx-swap="delete"
to tell HTMX to NOT insert the response and just delete the existing element:
<a
hx-delete="/tasks/{{task['id']}}"
hx-swap="delete"
hx-target="closest li"
>X</a>
hx-target
tells what hx-swap
should work on and can take a CSS selector. But it also has a few keywords you can use to make your life easier. Here, we use the closest
keyword before the CSS selector li
so tell HTMX to... select the closest li element. I don't know what more to tell you :)
To summarize, we say here:
Send an HTTP request with "DELETE /tasks/78689" (78689 or whatever the task id is).
When the response comes back, ignore it, and delete an element in the current page.
The element you must delete is the closest li, which is the parent of our link in this template.
That's for deletion, but what about toggling our "done" / "not done" task status?
We need to change our old <input>
, which was:
<input type="checkbox" id="task{{task['id']}}"
name="task"
value="{{task['id']}}"
% if task['done']:
checked
% end
>
To make it send a PUT request when the checkbox is ticked:
<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"
>
hx-trigger="change"
tells HTMX to do something on the change
event. We task HTMX to send a PUT request to the URL given by hx-put
. hx-target
expresses that when the response comes back, we want HTMX to swap it with the closest li.
The only really new thing here is the outerHTML
.
Remember that if hx-target
says what to swap the response with (this is stuff you want to replace), hx-swap
is to tell to HTMX how to insert the response. We can tell HTMX to say "just before this", "inside this", etc.
The default value for hx-swap
is innerHTML
meaning, "replace the target content with the response". It will place it inside. We won't want that, we want the response to swap the target itself, because one li
must replace another li
, not be put inside it. That's what outerHTML
does.
What we expressed so far:
When the checkbox is ticked (a.k.a, when a change event occurs on the
<input>
), do something.The something is to send a
PUT
request to "/tasks/8709..."When the response arrives, swap its content with the closest
li
, which is the parent of our checkbox.When you swap, don't insert inside, replace the whole old
li
with the newli
from the response.
Bird eye view
Our new server.py (colored version):
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("/tasks/", method="POST")
def add():
title = request.forms.get("title")
if title:
todo.add_todo(title)
redirect("/")
@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))
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)
return ""
if __name__ == "__main__":
debug(True)
run(host="localhost", port=8080, reloader=True)
Our new index.html (colored version):
<!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="/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>
<ul>
% for task in tasks:
% include('_task.tpl', task=task)
% end
</ul>
</div>
</body>
</html>
And the _task.tpl that comes with it (colored version):
<li>
<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"
>
<label for="task{{task['id']}}">
{{task['title']}}
</label>
<a
hx-delete="/tasks/{{task['id']}}"
hx-swap="delete"
hx-target="closest li"
>X</a>
</li>
We are done.
To try it, clone the granular-task-updates tag, or download the zip and run server.py
from inside the directory "7_granular_task_updates".
Wait...
We have unfinished business!
Adding a new task still swaps the whole body.
There is also a weird thing: when you reload the page, the browser sometimes fills in unchecked inputs (!?).
And worse, now the counter is out of sync when you mark a task as done or delete one.
We better have another article to fix that. With fancy events and stuff.
excellent articles! There's something I can't interpret though, in:
id="task{{task['id']}}"*
what is the asterisk for?
I think you forgot to mention removing the second <form> </form> tags around the <ul>. It's removed in the final file, but the article didn't mention the need to remove it. As I was enjoying manually editing and changing files, rather than downloading them, this had me stumped as you could set a task as done, but it would not allow you to unmark as done. Two calls being made to the server.