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.

comments

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.

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.

Hello htmx

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.

Basic markup

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.

Add todo

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.

Add todo finished

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.

Add todo finished

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.

Todos counter

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.

Todos counter

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.

Filters

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.

Deleting

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.

Completed

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
Did you like my post? Share it with friends!
Don't miss a thing!
Follow me on Youtube, Twitter or Instagram.
Oleksandr Kocherhin
Oleksandr Kocherhin is a full-stack developer with a passion for learning and sharing knowledge on Monsterlessons Academy and on his YouTube channel. With around 15 years of programming experience and nearly 9 years of teaching, he has a deep understanding of both disciplines. He believes in learning by doing, a philosophy that is reflected in every course he teaches. He loves exploring new web and mobile technologies, and his courses are designed to give students an edge in the fast-moving tech industry.