HTMX Crash Course - It's Not a React Alternative
In this post you will learn what is HTMX and how it differs from frontend frameworks. And in this HTMX tutorial we will create a todo application so you can see how we can build an application without frontend framework.
The idea
What is the idea of HTMX? We don't write Javascript at all, we don't have some frontend framework but we have some bindings to our default HTML.
<button hx-post="/clicked" hx-swap="outerHTML">
Click me
</button>
Like we have a click event on the button and we can attach some attributes on our HTML.
Also it is important to remember that HTMX is extremely small.
HTMX gives you access to AJAX, Websockets and CSS Transitions directly in HTML through attributes. Which means we add some attributes to elements without writing Javascript.
For example our button that you just saw will send a POST API call to the url /clicked
. Then it will take a markup that our API must return and put it inside this element.
HTMX gets markup from backend at not JSON data.
It doesn't work like React where we build all markup from scratch. It uses existing backend rendered markup.
Why HTMX?
The main question here is why do we need HTMX at all when we have frameworks like React or Angular and a lot of people are used to them. HTMX is completely different.
Previously we already had tools which worked in a similar way. Hotwire / Turbo did exactly that by just replacing existing markup with new markup and these tools typically exist inside backend frameworks like for example Ruby on Rails.
The main point to remember is that HTMX is not an alternative to frontend framework.
If you have a use case where you need a small amount of Javascript just to animate your comments for example and to avoid page reloading or just to implement infinite scrolling these are exactly use cases for HTMX.
Project planning
The goal of this post is to build TODO MVC project.
This is the list of todos that we can add, edit or delete. We can build this stuff without frontend framework just with HTMX.
import express from "express";
import bodyParser from "body-parser";
import path from "path";
const app = express();
app.set("views", path.join(__dirname, "views"));
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, "assets")));
app.listen(3012, () => {
console.log("Project started");
});
Here is a small express project which I prepared for us. Additionally there are 2 CSS files which you can take from the source code under this post.
Additionally we have an interface for our Todo
.
export type Todo = {
id: string;
name: string;
isCompleted: boolean;
};
This is exactly the entity that we will use in our application. And Typescript is not mandatory to use HTMX, it is just easier for me do write code this way.
Configuring views
As I already said we respond to the API calls with markup. This is why we need to install something that can generate markup for our response. I want to use here a package which is called Pug. It is not mandatory, you can use any package which can generate markup.
npm i pug
Now I want to create a views
folder where we will store all template files.
// src/views/index.pug
doctype html
html(lang='en')
head
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1')
link(rel='stylesheet' href='/css/todo-mvc-base.css')
link(rel='stylesheet' href='/css/todo-mvc-app.css')
body
script(src='https://unpkg.com/htmx.org@1.9.5')
script(src='https://unpkg.com/hyperscript.org@0.9.11')
Here I created basic markup and as you can see the whole Pug markup is build around indentation. We don't write open/close tags like we do in plain html. We indent every single tag which is exactly how we put one tag inside another.
Also here we injected 2 scripts: for htmx
and hyperscript
which is a dependency for htmx
for some attributes.
body
h1 Hello HTMX
Here we added an h1
tag to the body.
Now let's render this template.
app.set("view engine", "pug");
...
app.get("/", (req, res) => {
res.render("index");
Here we have an important line. We set our view engine
as pug
so express uses correct library to create a markup. After that we render our template on /
route.
As you can see in browser our template is rendered.
Adding markup
Now we need to store a list of todos
inside our node application.
const app = express();
let todos: Todo[] = [];
...
This is just an array that we can modify later.
app.get("/", (req, res) => {
res.render("index", {todos});
Now we provided these list of todos as a variable to our template.
doctype html
html(lang='en')
head
meta(charset='utf-8')
meta(name='viewport' content='width=device-width, initial-scale=1')
link(rel='stylesheet' href='/css/todo-mvc-base.css')
link(rel='stylesheet' href='/css/todo-mvc-app.css')
body
.todoapp
header.header
h1 todos
form
input.new-todo(
placeholder="What needs to be done?"
name="name"
autoFocus=""
section.main
ul.todo-list
footer.footer
ul.filters
li
a(
href="/?filter=all"
) All
li
a(
href="/?filter=active"
) Active
li
a(
href="/?filter=completed"
) Completed
script(src='https://unpkg.com/htmx.org@1.9.5')
script(src='https://unpkg.com/hyperscript.org@0.9.11')
Here we added our form, main and footer with links.
As you can see we successfully rendered our basic markup.
Adding todos
Now let's talk about creating of new todos. We already have a form with the input.
form(
hx-post="/todos"
)
Here we added an attribute hx-post
which means that when we submit our form it sends a request to /todos
route.
Now we need to implement /todos
route.
npm i uuid
But in order to generate an ID we must install one more package. uuid
allows us to do exactly that.
import { v4 as uuid } from "uuid";
app.post("/todos", (req, res) => {
const newTodo: Todo = {
id: uuid(),
name: req.body.name,
isCompleted: false,
};
todos.push(newTodo);
console.log(newTodo);
res.send(200)
});
Here we added /todos
route where we create a new todo and add it to the todos
list.
Now we must return a markup in our POST
request. But in order to do that we must create todo-item
template.
// src/views/includes/todo-item.pug
li
.view
label #{todo.name}
Here we a markup for our todo-item
and in #{}
we can render any properties that we provide inside. So we must provide todo
to render this template.
import pug from "pug";
...
app.post("/todos", (req, res) => {
const todoItemTemplate = pug.compileFile(
path.join(__dirname, "views/includes/todo-item.pug")
);
const todoItemMarkup = todoItemTemplate({ todo: newTodo });
res.send(todoItemMarkup);
});
In order to prepare markup we must call pug.compileFile
which store a template. After this we must call it and provide needed properties inside. At the end in todoItemMarkup
we are getting plain HTML markup.
As you can see after we submit our form it is being replaced with the markup that we got from our POST request.
This is the default behavior of HTMX. It replaces the element that sends the request with returned markup.
But it is not what we want. We want to add this markup to another place.
form(
hx-post="/todos"
hx-target="#todo-list"
hx-swap="afterbegin"
)
...
ul.todo-list#todo-list
With hx-target
we tell what element we want to modify instead. And in hx-swap
we tell how we should modify it. afterbegin
means that we want to prepend the returned markup. So it will be added to the beginning of our list.
Also we must add an ID todo-list
to our ul
in order to tell HTMX which element it must use.
As you can see our new todo was added to the list.
But now here we need some Hyperscript magic. We want to clear an input after our todo was created.
form(
hx-post="/todos"
hx-target="#todo-list"
hx-swap="afterbegin"
_="on htmx:afterOnLoad set #new-todo.value to ''"
)
input.new-todo#new-todo(
placeholder="What needs to be done?"
name="name"
autoFocus=""
)
Here we have an _
attribute which listens to HTMX event htmx:afterOnLoad
and sets an input value to an empty string.
htmx:afterOnLoad happens after our request was successfully made.
The last thing that we want to do is to render all our todos in our markup so when we reload the page they are there.
ul.todo-list#todo-list
each todo in todos
include includes/todo-item
As we already passed todos
property to the template we can just render them in the loop using todo-item
template.
Now even after page reload our todos are rendered on initialize.
Todos counter
The next thing that we want to implement is a counter of our todos. But it is quite tricky to do in HTMX.
Our first step here is to create a new view for our counter.
// src/views/includes/item-count.pug
span#todo-count.todo-count(hx-swap-oob="true")
strong #{itemsLeft} item left
It's just a template with a variable itemsLeft
but we added a strange attribute hx-swap-oob
. What it does?
We use this attribute to swap our template when we get a response back.
Why do we need that at all? So we create our todos and we want to render counter at the bottom. By default this counter will be never updated. But we want to rerender it every single time when we add or remove a todo.
In order to implement that we can return markup from item-count
file and an attribute hx-swap-oob
will swap the markup by this id todo-count
.
app.post("/todos", (req, res) => {
...
const todoItemTemplate = pug.compileFile(
path.join(__dirname, "views/includes/todo-item.pug")
);
const todoItemMarkup = todoItemTemplate({ todo: newTodo });
const itemCountTemplate = pug.compileFile(
path.join(__dirname, "views/includes/item-count.pug")
);
const itemCountMarkup = itemCountTemplate({ itemsLeft: 20 });
res.send(todoItemMarkup + itemCountMarkup);
});
Now inside our /todos
we prepare the template not only for todoItem
but also for itemCount
. And inside we provide 20 just for test.
Most importantly we concatenaty 2 markups together and return them.
HTMX will see an ID in markup and put that part in the correct place to rerender our counter because of
hx-swap-oob
tag.
...
footer.footer
include includes/item-count.pug
ul.filters
...
We must also render our template in the footer.
As you can see in browser our counter is rendered and we see 20
. Of course it is not correct and we must calculate correctly itemsLeft
property.
const getItemsLeft = () => todos.filter((todo) => !todo.isCompleted).length;
...
app.get("/", (req, res) => {
res.render("index", {
todos,
itemsLeft: getItemsLeft()
});
});
...
app.post("/todos", (req, res) => {
...
const itemCountMarkup = itemCountTemplate({ itemsLeft: getItemsLeft() });
res.send(todoItemMarkup + itemCountMarkup);
});
Here we created getItemsLeft
function which returns the amount of not completed todos. Then we provided this information in our index template and when we calculate counter markup to update.
As you can see in browser our counter is rerendered correctly with every new todo.
Adding filters
Now we must do exactly the same with filters. We must filter our data and provide not all todos in the template but filter them depending on what tab is activated.
const getFilteredTodos = (filter: unknown) => {
if (filter === "active") {
return todos.filter((todo) => !todo.isCompleted);
} else if (filter === "completed") {
return todos.filter((todo) => todo.isCompleted);
} else {
return todos;
}
};
...
app.get("/", (req, res) => {
res.render("index", {
todos: getFilteredTodos(req.query.filter),
itemsLeft: getItemsLeft(),
filter: req.query.filter,
});
});
Here we created getFilteredTodos
function which returns only todos needed for activated tab. Then we provide these todos in our template based on filter
query parameter that we got from URL.
As you can see our todos are filtered and we don't show any if we don't have completed todos when we activated completed tab.
But we have a problem with styling because we didn't highlight active tab. As we passed a filter
property inside we can easily apply these styles.
ul.filters
li
a(
href="/?filter=all"
class={selected: filter === 'all'}
) All
li
a(
href="/?filter=active"
class={selected: filter === 'active'}
) Active
li
a(
href="/?filter=completed"
class={selected: filter === 'completed'}
) Completed
Here we add class if filter
equals tab name.
Deleting todos
The next thing that we want to implement is deleting of our todos.
li(
id="todo-" + todo.id
)
.view
label #{todo.name}
button.destroy(
hx-delete="/todos/" + todo.id
_="on htmx:afterOnLoad remove #todo-" + todo.id
)
Here we added a button which will call /todos/id
DELETE API when clicked. As we want to remove the li
element we added a hypescript logic with htmx:afterOnLoad
and we tell that we want to remove the element by ID.
Now we must implement this DELETE request.
app.delete("/todos/:id", (req, res) => {
todos = todos.filter((todo) => todo.id !== req.params.id);
const itemCountTemplate = pug.compileFile(
path.join(__dirname, "views/includes/item-count.pug")
);
const itemCountMarkup = itemCountTemplate({ itemsLeft: getItemsLeft() });
res.send(itemCountMarkup);
});
Here we update our list of todos
and compile itemCount
template which we return. We must do that because we want to update our todos counter.
As you can see in browser our cross is there and when we click it our element is being removed from the list.
Completing todos
The last thing that we want to implement is completing of our todos.
li(
id="todo-" + todo.id
)
.view
input.toggle(
type="checkbox"
checked=todo.isCompleted
hx-patch="/todos/" + todo.id
hx-swap="outerHTML"
hx-target="#todo-" + todo.id
)
label #{todo.name}
button.destroy(
hx-delete="/todos/" + todo.id
_="on htmx:afterOnLoad remove #todo-" + todo.id
)
Here we added a toggle checkbox which sends a PATCH request to /todos/id
url. We want to rerender the whole element to apply different styling to it this is why it's hx-swap
outerHTML and we target our li
.
Now let's add this route.
app.patch("/todos/:id", (req, res) => {
const todo = todos.find((todo) => todo.id === req.params.id);
if (!todo) {
return res.sendStatus(404);
}
todo.isCompleted = !todo.isCompleted;
const todoItemTemplate = pug.compileFile(
path.join(__dirname, "views/includes/todo-item.pug")
);
const todoItemMarkup = todoItemTemplate({ todo });
const itemCountTemplate = pug.compileFile(
path.join(__dirname, "views/includes/item-count.pug")
);
const itemCountMarkup = itemCountTemplate({ itemsLeft: getItemsLeft() });
res.send(todoItemMarkup + itemCountMarkup);
});
Here we try to find a todo and set it to completed. We also prepare 2 template for todo-item
and for item-count
and return them as a response.
As you can see in browser we have a completed toggle that can change the state of our todo.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!
📚 Source code of what we've done