FastHTML is a brilliant library fresh from Jeremy Howard and Answer.AI that enables Python developers to create modern interactive web apps.
- FastHTML bears similarity with FastAPI, but while FastAPI is designed to return JSON data for building APIs, FastHTML is aimed at returning HTML data.
- FastHTML leverages HTMX, which overcomes HTML’s limitations by allowing any element to trigger requests and update parts of a page without a full refresh of the entire page.
In this post, we walk through a basic version of the Todo list example code. Please also refer to Jeremy’s excellent video here and here.
Main todo page
The app source code is in app.py
, and can be run with python app.py
.
The main page looks like this:
Here is the code to display this page:
@app.get("/")
async def get_todos(req):
= Form(Group(mk_input(), Button("Add")),
add ="/", target_id=id_list, hx_swap="beforeend")
hx_post= Card(Ul(*TODO_LIST, id=id_list),
card =add, footer=Div(id=id_curr)),
headerreturn Titled('Todo list', card)
Where:
TODO_LIST
is maintained as a simple list ofTodoItem
s.
@dataclass
class TodoItem():
str; id: int = -1; done: bool = False
title: def __ft__(self):
= AX(self.title, f'/todos/{self.id}', id_curr)
show = AX('edit', f'/edit/{self.id}', id_curr)
edit = ' (done)' if self.done else ''
dt return Li(show, dt, ' | ', edit, id=tid(self.id))
= [TodoItem(id=0, title="Start writing todo list", done=True),
TODO_LIST id=1, title="???", done=False),
TodoItem(id=2, title="Profit", done=False)] TodoItem(
id_list
is a variable defined to be"todo_list"
, which is the id of theul
element that displays the list of todos.
= 'current-todo'
id_curr = 'todo-list'
id_list def tid(id): return f'todo-{id}'
mk_input
creates an input field.
def mk_input(**kw):
return Input(id="new-title", name="title", placeholder="New Todo", **kw)
We have a Titled
object with a title Todo list
and a Card
object. The Card
has three components: an unordered list UL
, a header
, and a footer
.
- The
header
deals with adding todo - the body
UL
lists todos, the ul element has idtodo-list
. - The
footer
is currently empty, but we have itsid
so we can later populate its content.
<footer>
<div id="current-todo"></div>
</footer>
Let’s look at them one by one.
Add todo
<header>
<form hx-post="/" hx-swap="beforeend" hx-target="#todo-list">
<fieldset role="group">
<input name="title" placeholder="New Todo" id="new-title">
<button>Add</button>
</fieldset>
</form>
</header>
It is itself a Form
, which has several components
- The form’s fields is a
Group
that has an input box created bymk_input()
, and aButton
named “Add”. - When this form is submitted, it will trigger a
post
request to the root endpoint.
@app.post("/")
async def add_item(todo:TodoItem):
id = len(TODO_LIST)+1
todo.
TODO_LIST.append(todo)return todo
As we can see, this post
request will add a todo to the “TODO_LIST” and then return the new todo
. Since we have hx-swap
set to "beforeend"
and hx-target
set to "#todo-list"
back in the form submission code, and since "todo-list"
is the id of the ul
element that lists the todos, the new todo
will be inserted before the end of the displayed todo list.
But there is a problem, our input form still displays the new entry that we just typed in even after the insertion:
To get rid of it we can re-create (reset) the input field in the form in addition to returning the todo
:
@app.post("/")
async def add_item(todo:TodoItem):
id = len(TODO_LIST)+1
todo.
TODO_LIST.append(todo)return todo, mk_input()
But the problem is the new input was returned and also inserted into the todo list (under the new todo), it is not replacing the true input field as we wanted:
How do we make sure the new input created actually replaces the input field we already have in the header?
Note that here is the current input field:
<input name="title" placeholder="New Todo" id="new-title">
Its id is "new-title"
, when we use the same mk_input
to re-create an input field, it has the same id "new-title"
. We can use a feature called OOB (out of band) swapping. In this mode, if it finds a matching id in the current web page’s DOM, HTMX will swap the existing element with the new one from the response. The hx_swap_oob='true'
attribute ensures this swap happens outside the normal response flow, targeting the element with the matching id. So here is what we want to do:
@app.post("/")
async def add_item(todo:TodoItem):
id = len(TODO_LIST)+1
todo.
TODO_LIST.append(todo)return todo, mk_input(hx_swap_oob='true')
Now it works:
Here is a recap of the process:
- Initial Form Rendering: The form with the input field (
mk_input()
) and theAdd
button is rendered on the page. - User Action: The user enters a new todo item and clicks the
Add
button. - Form Submission: The form is submitted via
hx_post="/"
. - Server Response: The server processes the request and returns the new todo item along with
mk_input(hx_swap_oob='true')
. - HTMX OOB Handling: HTMX processes the response, sees the
hx_swap_oob='true'
attribute, and looks for the element with theid="new-title"
in the current DOM. - Element Replacement: HTMX replaces the existing input field (with
id="new-title"
) with the new input field from the response, effectively resetting it.
List todo
The Card
consists of an unordered list TODO_LIST
with id todo-list
as follows:
<ul id="todo-list">
<li id="todo-0">
<a href="#" hx-get="/todos/0" hx-target="#current-todo">Start writing todo list</a>
(done)
| <a href="#" hx-get="/edit/0" hx-target="#current-todo">edit</a>
</li>
<li id="todo-1">
<a href="#" hx-get="/todos/1" hx-target="#current-todo">???</a>
| <a href="#" hx-get="/edit/1" hx-target="#current-todo">edit</a>
</li>
<li id="todo-2">
<a href="#" hx-get="/todos/2" hx-target="#current-todo">Profit</a>
| <a href="#" hx-get="/edit/2" hx-target="#current-todo">edit</a>
</li>
</ul>
Let’s look at one item:
<li id="todo-0">
<a href="#" hx-get="/todos/0" hx-target="#current-todo">Start writing todo list</a>
(done)
| <a href="#" hx-get="/edit/0" hx-target="#current-todo">edit</a>
</li>
What produced this htmx code is the first item in the TODO_LIST
:
id=0, title="Start writing todo list", done=True), TodoItem(
Let’s examine the TodoItem
class:
@dataclass
class TodoItem():
str; id: int = -1; done: bool = False
title: def __xt__(self):
= AX(self.title, f'/todos/{self.id}', id_curr)
show = AX('edit', f'/edit/{self.id}' , id_curr)
edit = ' (done)' if self.done else ''
dt return Li(show, dt, ' | ', edit, id=tid(self.id))
So the __xt__
method created the magic, it produces:
' | ', edit, id=tid(self.id)) Li(show, dt,
where
= AX(self.title, f'/todos/{self.id}', id_curr) show
renders
<a href="#" hx-get="/todos/0" hx-target="#current-todo">Start writing todo list</a>
So AX
produces an HTMX ancher (a) element. Note that id_curr = 'current-todo'
, self.title
is 'Start writing todo list'
, and self.id
is 0
for the first item. Let’s ask Co-pilot to explain this HTML code:
The HTML anchor (<a>
) tag is using attributes from the Hypertext (HTMX) library, which allows for AJAX-like behavior directly in HTML without writing JavaScript. Here’s a breakdown of what this tag does:
<a href="#">
: This is a standard anchor tag with anhref
attribute pointing to#
, which means it links to the top of the current page. This is often used as a placeholder when the actual navigation is handled by JavaScript or, in this case, HTMX.hx-get="/todos/0"
: This HTMX attribute specifies that when the anchor is clicked, an HTTPGET
request should be made to the URL/todos/0
. This is typically used to fetch data from the server without reloading the page.hx-target="#current-todo"
: This attribute tells HTMX where to place the response from the GET request. The value#current-todo
is a CSS selector that identifies an element with the IDcurrent-todo
. The content fetched from/todos/0
will be inserted into this element, allowing for dynamic updates of part of the page based on user interaction.class=""
: This is an empty class attribute, which means no CSS classes are applied to this anchor tag. It’s included in the example but doesn’t affect the HTMX functionality.
In summary, when a user clicks on this anchor tag, HTMX will fetch data from /todos/0
and insert the response into the element with the ID current-todo
on the current page, without reloading the entire page. This allows for a more interactive and responsive user experience.
So with AX(self.title, f'/todos/{self.id}', id_curr)
, we simply provide the title text, the content to fetch(hx-get
), and the element ID (hx-target
) where the response of the get
operation should be placed. Here we have id_curr = 'current-todo'
. But where is that element?
= Card(Ul(*TODO_LIST, id=id_list),
card =add, footer=Div(id=id_curr)), header
So it is in the footer
of the Card
object.
<footer>
<div id="current-todo"></div>
</footer>
Delete todo
Therefore, if we click the title text “Start writing todo list”, we will execute the get
to "/todos/0"
, which is the following:
@app.get("/todos/{id}")
async def get_todo(id:int):
= find_todo(id)
todo = Button('delete', hx_delete=f'/todos/{todo.id}', target_id=tid(todo.id), hx_swap="outerHTML")
btn return Div(Div(todo.title), btn)
Here the Div(Div(todo.title), btn
renders the following under the "current-todo"
element in the footer
<footer>
<div id="current-todo" class=""><div>
<div>Start writing todo list</div>
<button hx-delete="/todos/0" hx-swap="outerHTML" hx-target="#todo-0">delete</button>
</div>
</div>
</footer>
One Div
for the title of the todo, and the other for a button. The button will have name “delete”.
Clicking on the “delete” button will trigger a delete
operation to "/todos/0"
:
def find_todo(id):
return next(o for o in TODO_LIST if o.id==id)
def clr_details():
return Div(hx_swap_oob='innerHTML', id=id_curr)
@app.delete("/todos/{id}")
async def del_todo(id:int):
id))
TODO_LIST.remove(find_todo(return clr_details()
It removes the corresponding todo task from the TODO_LIST
, and then returns a Div
from clr_details
. In clr_details
, we used hx_swap_oob='innerHTML'
. This is HTMX’s out-of-band (OOB) swapping feature as we mentioned earlier, to specify how the HTMX response should be swapped into the DOM somewhere other than the target. The value 'innerHTML'
means that once it finds an element in the current document with the corresponding id
, it will replace its inner HTML with the Div
element returned by the method. The id
is id_curr
, which have value current-todo
, and it is the element opened at the footer
. In this case the Div
element returned by the clr_details
method does not contain any child element or text content. Therefore, it is effectively replacing the inner HTML of the current-todo
element with empty content, or cleaning it. In other words, the del_todo
method clears the current-todo
in the footer
. That’s what happened when we click the “delete” button and trigger the '/todos/0'
end point. But there is more in the button than this detete
endpoint:
= Button('delete', hx_delete=f'/todos/{todo.id}', target_id=tid(todo.id), hx_swap="outerHTML") btn
<button hx-delete="/todos/0" hx-swap="outerHTML" hx-target="#todo-0">delete</button>
In particular, its hx-swap="outerHTML"
indicates that the target element with id
"todo-0"
’s entire HTML should be replaced (including its content and the element itself). Here the effect is that the "todo-0"
element is replaced by an empty block, which makes it disappear in the "todo-list"
element. It uses hx-swap
instead of hx-swap-oob
because the HTMX response here is being swapped directly into the target. In other words, we have to use hx-swap-oob
when we do not have a target id specified.
Common values for hx-swap
(also applicable for hx-swap-oob
)
innerHTML
: Replace the target element’s content only with the responseouterHTML
: Replace the target element all together with the responsebeforebegin
: Insert the response before the target elementafterbegin
: Insert the response before the first child of the target elementbeforeend
: Insert the response after the last child of the target elementafterend
: Insert the response after the target elementdelete
: Deletes the target element regardless of the responsenone
: Do nothing but out of band items will still be processed
The requests are triggered by natural events by default:
input
,textarea
,select
are triggered on thechange
eventform
is triggered on thesubmit
event- everything else is triggered by the
click
event
The default triggers can also be modified using hx-trigger
.
Edit todo
Now similarly, we have the “edit” anchor element that connects to the edit
endpoint from the __xt__
method of TotoItem
class. What we want is to display another form that allows us to input the content of the todo, and a button to save the todo, as well as a checkbox indicating whether the todo is done.
= AX('edit', f'/edit/{self.id}' , id_curr) edit
It produces the following HTMX ancher element:
<a href="#" hx-get="/edit/0" hx-target="#current-todo">edit</a>
Clicking the "edit"
would send a get
request to the ‘edit’ endpoint:
@app.get("/edit/{id}")
async def edit_item(id:int):
= find_todo(id)
todo = Form(Group(Input(id="title"), Button("Save")),
res id="id"), Checkbox(id="done", label='Done'),
Hidden(="/", target_id=tid(id), id="edit")
hx_put
fill_form(res, todo)return res
This is what we got after clicking “edit” next to the third item “Profit”.
A few things that are different in this form from the prior form:
- we created a “Hidden” Input field for storing the id of the todo item.
- we created a Checkbox with a label “Done”
- we use a
fill_form
method to fill out the form (instead of waiting for user input).
The hidden id field ensures that the id of the todo item is sent along with the form submission. This is crucial for identifying which todo item is being edited on the server side when the form is submitted. In this case, the input ‘todo’ has an attribute id
, the value of the id
for the “Profit” todo is 2. So it will be assigned to the hidden input.
Note that although we specify async
here, the operations inside the edit_item
method are all executed synchronously (unless we explicitly use await
for asynchronous tasks).
Here is a step by step explanation:
- Retrieving the Todo Item:
- The
edit_item
function retrieves the todo item by callingfind_todo(id)
with the specified id (in this case, id=2). - This function searches the
TODO_LIST
and returns the todo item with the id of 2.
- Creating the Form:
- A
Form
element is created with an input field for the title, a hidden input field for the id, and a checkbox for the done status. - The hidden input field is created with
Hidden(id="id")
.
- Filling the Form:
- The
fill_form
function is called with the form (res) and the retrieved todo item as arguments. - The
fill_form
function fills named items in the “res”form
with attributes in “todo”obj
. Here the “todo” object has a “title” attribute with value “Profit”, an “id” attribute with value “2”, and a “done” attribute with value “False”. The form has a “title”Input
, a “done”Checkbox
, and an “id” hidden input field. The “todo”’s values will therefore be assigned to theres
form. This filled form is immediately returned as a server response. - If we recall the AX element that triggers this:
<a href="#" hx-get="/edit/2" hx-target="#current-todo">edit</a>
The response should be presented in the target element "current-todo"
which is in the "footer"
section!
Save edit
OK. Now let’s see what happens after we change “Profit” to “Profitable” and click the “Save” button. The click submits the edit form that were rendered after clicking “Edit”.
@app.get("/edit/{id}")
async def edit_item(id:int):
= find_todo(id)
todo = Form(Group(Input(id="title"), Button("Save")), Hidden(id="id"), Checkbox(id="done", label='Done'), hx_put="/", target_id=tid(id), id="edit")
res
fill_form(res, todo)return res
In the form definition we specified a hx_put="/"
for “Save” button submission, which means HTMX will intercept the form submission and trigger an asynchronous PUT
request to the server.
@app.put("/")
async def update(todo: TodoItem):
id))
fill_dataclass(todo, find_todo(todo.return todo, clr_details()
But what’s really happening underneath is the following:
<form hx-put="/" hx-target="#todo-2" id="edit" name="edit">
<fieldset role="group">
<input id="title" name="title" value="Profit">
<button>Save</button>
</fieldset>
<input type="hidden" value="2" id="id" name="id">
<label>
<input type="checkbox" value="1" id="done" name="done">
Done</label>
</form>
After the user changes the title from “Profit” to “Profitable”
<form hx-put="/" hx-target="#todo-2" id="edit" name="edit">
<fieldset role="group">
<input id="title" name="title" value="Profitable">
<button>Save</button>
</fieldset>
<input type="hidden" value="2" id="id" name="id">
<label>
<input type="checkbox" value="1" id="done" name="done">
Done</label>
</form>
Note however, this is not directly reflected in the page source because it still reflects the static attribute value of the element. In order to see the dynamic property value of the element, we can use the “Properties” tab, as shown below:
From here we can see the DOM element’s "title"
property value has indeed been changed to “Profitable”. (we might need to re-start inspect to see it)
Next, the hx-put="/"
attribute tells the Browser HTMX to send an HTTP PUT
request to the server when the form is submitted. HTMX serializes the form data into a format suitable for sending in the request body. For example, it can be JSON format
{
"id": 2,
"title": "Profitable",
"done": true
}
When the server receives the request, it comes with the serialized form data. It will parse the data into the expected class, in this case the dataclass
.
@dataclass
class TodoItem():
str; id: int = -1; done: bool = False title:
The resulting todo
object will become the input to the endpoint’s update
method:
@app.put("/")
async def update(todo: TodoItem):
id))
fill_dataclass(todo, find_todo(todo.return todo, clr_details()
What the fill_dataclass
do is to use the source parameter to update the destination parameter.
def fill_dataclass(src, dest):
"Modifies dataclass in-place and returns it"
for nm,val in asdict(src).items(): setattr(dest, nm, val)
return dest
This source is the todo
that is passed in, which is one that has been edited, we use that to replace the existing todo
of the same id
. Then we return the edited todo
, and as we discussed earlier, use clr_details
to clean the "current-todo"
element, making the "save"
form disappear!
Difference between Attributes and Properties
Attributes are defined in the HTML markup. They provide additional information about HTML elements. They are static and are not automatically updated when the state of the element changes. When an HTML element is created, its attributes are set based on the values specified in the markup.
Properties are part of the DOM objects. They represent the current state of the element. Properties are dynamic and reflect the current state of the element. When you interact with an element (e.g., change the value of an input field), the property is updated to reflect the current state.
What’s next
The FastHTML team offers various versions of the Todo list example with different levels of complexity. A natural improvement to the basic example would be using a database instead of a handcrafted Python list to store the todos. These additional versions are available here, here, and here.
In addition to the Todo list example, there is the Image Generation App example that illustrates how to create a grid interface to display a list of images users can generate through an AI model, and ways to manage user sessions and credits. A Chatbot example shows how to create a chat bot interface using DaisyUI chat bubble components. Finally, the Multiplayer Game of Life demonstrates the use of Websockets for an interactive web app. h2x is a tool to convert HTML to FastTags.
Conclusion
In this walkthrough, we explored the key functionalities of the FastHTML library using a Todo list application as an example. We delved into creating, displaying, editing, and deleting todo items, highlighting how FastHTML leverages HTMX for seamless, interactive web experiences. The integration of asynchronous endpoints and dynamic DOM manipulation provides a powerful and efficient way to build modern web applications. For more details, please refer to the FastHTML project page.