HTMx - tutorial part III - Building the application

· January 15, 2025

This is the third post in a series that I’m making about HTMx that I think is utterly amazing and will change how I (we?) write web apps in the future. You are more than welcome to read from here, but it will be hard following along code-wise if you haven’t stepped through part I and part II first

In this part we will build the core logic of the application and start to create todo-items and store them in Firestore.

Store data in Firestore

Let’s get the storage stuff out of the way first. I really want to write some HTMx stuff, but we’ll be better off having somewhere to store the items.

Head on over to the Firebase Console and create a database in a region of your choice.

Create a rule that looks like this, to allow authenticated users to read and write:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // Allow users to read/write their own todos
    match /{userId}/{document=**} {
      allow read, write: if request.auth.uid == userId;
    }
  }
}

Configuration application

We will use the firebase SDK to access Firestore, so install it with npm i firebase.

Then create a firebase app, by configuring it like this (I’ve put this in lib/firebase/config.js):

import { initializeApp } from "firebase/app";
import { getFirestore } from 'firebase/firestore';
import { getAuth } from 'firebase/auth';

const firebaseConfig = {
  apiKey: process.env.FIREBASE_APIKEY,
  authDomain: process.env.FIREBASE_AUTHDOMAIN,
  projectId: process.env.FIREBASE_PROJECTID,
  storageBucket: process.env.FIREBASE_STORAGEBUCKET,
  messagingSenderId: process.env.FIREBASE_MESSAGINGSENDERID,
  appId: process.env.FIREBASE_APPID,
};

const app = initializeApp(firebaseConfig);

export const db = getFirestore(app);
export const auth = getAuth(app);

Yes. Let’s play it safe and put all those keys in the .env file.

Write a service

Create this file in lib/firebase/todoService.js:

import {
  collection,
  getDocs,
  doc,
  getDoc,
  updateDoc,
  deleteDoc,
} from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";

import { db, auth } from "./config.js";

async function getCurrentUser() {
  return new Promise((resolve, reject) => {
    const unsubscribe = onAuthStateChanged(auth, (user) => {
      unsubscribe();
      if (user) {
        resolve(user);
      } else {
        reject(new Error("User not authenticated"));
      }
    });
  });
}

export async function getAllTodos() {
  const user = await getCurrentUser();
  const userId = user.uid;
  const snapshot = await getDocs(collection(db, userId));
  return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
}

export async function getTodo(todoId) {
  const user = await getCurrentUser();
  const userId = user.uid;
  const docRef = doc(db, userId, todoId);
  const docSnap = await getDoc(docRef);
  if (docSnap.exists()) {
    return { id: docSnap.id, ...docSnap.data() };
  } else {
    return null;
  }
}

export async function updateTodo(todoId, updates) {
  const user = await getCurrentUser();
  const userId = user.uid;
  const docRef = doc(db, userId, todoId);
  return await updateDoc(docRef, updates);
}

export async function deleteTodo(todoId) {
  const user = await getCurrentUser();
  const userId = user.uid;
  const docRef = doc(db, userId, todoId);
  await deleteDoc(docRef);
}

export async function toggleTodoCompleted(todoId) {
  const user = await getCurrentUser();
  const userId = user.uid;
  const docRef = doc(db, userId, todoId);

  // Get the current completed status
  const docSnap = await getDoc(docRef);
  if (docSnap.exists()) {
    const currentCompleted = docSnap.data().completed || false; // Default to false if not set

    // Update with the toggled value
    await updateDoc(docRef, { completed: !currentCompleted });
  } else {
    throw new Error('Todo not found');
  }
}

This file is a pretty standard setup for storing data in for a logged in user.

Log in to Firebase

One thing is left to, is to ensure that our user is logged into the Firebase. Go to routes/auth.js and add the following lines to the router.post("/login"):

// Add these imports
import { auth } from "../lib/firebase/config.js";
import { signInWithCredential, GoogleAuthProvider } from 'firebase/auth';

router.post("/login", async (req, res) => {
  // As before until
  // const ticket = await client.verifyIdToken({

  // These lines to sign in to Firebase using the Google credential**
  const googleCredential = GoogleAuthProvider.credential(credential);
  await signInWithCredential(auth, googleCredential);

  // And then as before
});

HTMx introduction

Finally! Let’s dive into HTMx a bit more. I’ll do a very basic introduction and then I’ll redirect you to something deeper.

The thing that I love about HTMx is that it is so small and simple that you can explain it in 2-3 sentences. But at the same time powerful to move the world. With this “simple” tool you can build everything you want.

HTMx allows you to issue any type of HTTP request from any HTML element. It further allows you to decide where to target the response and how to replace the content of that target.

Consider these <div>:

<div hx-post="/increase" hx-target="#result" hx-swap="outerHTML">+</div>
<div id="result">0</div>

When this button is clicked a HTTP POST request is sent to the /increase endpoint. The result is then replacing the hx-target. A suitable return value could be:

<div id="result">0</div>

There are many things here that HTMx enables, that HTML doesn’t have:

  • We can issue any type of HTTP request from any type of HTML element
  • We can easily control where the response should be placed (hx-target). Be default it is the this, meaning the same element. But you address elements using any valid CSS selector and some more dynamic ways, such as closest and next.
  • Using hx-swap we have control how to change the target with the response. We could, for example, replace the entire element using outerHTML, append at the end or the beginning and much more.

HTMx Documentation

The HTMx website looks … simple and dated, but has everything you need:

  • The reference of attributes is very useful. And in combo with the docs is amazing.
  • There are many examples about how you do common (and some uncommon) tasks.
  • The essays gives you deeper understanding
  • And if you want to there’s a complete book on REST, HATEOAS and everything that you want to know on how and why HTMx was built.

This video will go through just about everything that you ever want to know, in break-neck speed.

HTMX - Create new todo

Let’s create a new todo and start by using HTMx to load the form. This is strictly not needed, but allows us to show off some HTMx features and build an application that looks a bit more like a SPA. We’re going to lazy load the form to create new todo items.

Show the form

First let’s create the todo-routes in a separate file (routes/todo.js), and create the first route that returns the form to create a new todo:

import express from "express";
const router = express.Router();

router.get("/new", (req, res) => res.render("todo/new.ejs"));

export default router;

Then mount these routes like this in server.js:

import todoRoutes from './routes/todo.js';
app.use("/todo", todoRoutes);

Then create the form in views/todo/new.ejs:

<form id="new-todo-form">
  <input type="text" name="title" placeholder="Title" required />
  <input type="date" name="duedate" required />
  <button type="submit">Add Todo</button>
</form>

We’ll soon add in the HTMx attributes.

Let’s clean up the main.ejs, make it a <main>-section, take out the hard-coded data and make the main section look like this:

<main class="todo-container">
<% if (!user) { %>
    <h2>Log in to see your To-do list</h2>
<% } else { %>
  <div hx-get="/todo/new" hx-trigger="load"></div>
  <div id="todo-list"></div>
<% } %>
</main>

Yes - that’s all we need. Later our todo-data will be injected into the todo-list using HTMX.

Notice the hx-trigger="load" that will issue a HTTP GET towards /todo/new when the div is loaded. That will then lazily load the list.

Also notice the <div id="todo-list"> which will be the place where the todo list is generated.

That is enough for now.

Create the todo

Let’s think about to what should happen when this form is posted:

  1. It should post to /todo
  2. The item should be stored in firebase, which generates an id property.
  3. HTML for the created item should be returned and prepended to the list called #todo-list (that we put in main.ejs)
  4. The form should be cleaned out

Let’s set up the form and button to issue the requests in the proper way :

<form id="new-todo-form" hx-on::after-request="this.reset()">
  <input type="text" name="title" placeholder="Title" required />
  <input type="date" name="duedate" required />
  <button type="submit" hx-post="/todo" hx-target="#todo-list" hx-swap="afterbegin">Add Todo</button>
</form>
  • With the help hx-post, hx-target and hx-swap we have told HTMx to post the form (it will automatically pick up all inputs in the form), and add the result to #todo-list
  • HTMx has a rich event system that we can hook into, using various hx-on-attributes. Here we using hx-on::after-request to get called after the request and reset the form. Yes, using some JavaScript, although we are using HTMx (the framework that promise that you shouldn’t write any JavaScript … sue me).

Let’s now build the backend that stores the todo in firestore and return a snippet of html to insert in #todo-list

import { addTodo } from "../lib/firebase/todoService.js";

router.post("/", async (req, res) => {
  const postedTodo = {
    title: req.body.title,
    duedate: req.body.duedate,
    completed: false,
  };
  const createdTodo = await addTodo(postedTodo);

  res.render("todo/todo-list-item.ejs", { todo: createdTodo });
});

And the todo/todo-list-item.ejs can look like this:

<div class="todo-item">
  <span class="todo-title"><%= todo.title %></span>
  <span class="todo-field"><%= todo.duedate %></span>
  <span class="todo-field"><%= todo.completed ? "✅" : "⏳" %></span>
</div>

(I made some styling changes here, that you can steal from the finished style-sheet)

Perfect we can now create new todo items. AND they get prepended to the list. However, if you reload the page the list is gone.

Lazy load the list of todos

That’s because we are using HTMx capabilities to dynamically update the list, on the client. However, we have never loaded the list of items from the start (or when the page is reloaded).

But we have everything we need to do that, in place (just about). Let’s lazily load the list. Here’s the backend in routes/todo.js:

import { addTodo, getAllTodos } from "../lib/firebase/todoService.js";

router.get("/", async (req, res) => {
  const todos = await getAllTodos();
  res.render("todo/list.ejs", {todos});
});

And here’s the template views/todo/list.ejs, that in turn calls out to views/todo/todo-list-item.ejs for some nice reuse:

<% todos.forEach(todo => { %>
  <%- include("todo-list-item.ejs", {todo}) -%>
<% }) %>

And main.ejs can now be rewritten like this.

<main class="todo-container">
  <% if (!user) { %>
  <h2>Log in to see your To-do list</h2>
  <% } else { %>
  <div hx-get="/todo/new" hx-trigger="load"></div>
  <hr />
  <div id="todo-list" hx-get="/todo" hx-trigger="load"></div>
  <% } %>
</main>

In all honesty; we could also have preloaded these two <div>s with server-side rendering, but now that we are trying HTMx out I thought it would be cool to use the lazy loading feature.

Morgan

Let’s add some logging capabilities to better see what we are calling in the back end. That is super simple with Morgan that is a middleware for Express:

npm i morgan

and then in server.js:

import morgan from "morgan";

// Middleware
app.use(morgan("dev"));

Restart the application and log in and you can now see the different routes being hit, through our lazy-loading of the application.

Toggle completion

Toggle completion will be implemented with a HTTP PUT and then I’ll just replace the entire #todo-item. Update the todo-list-item.ejs to look like this:

<div class="todo-item">
  <span class="todo-title"><%= todo.title %></span>
  <span class="todo-field"><%= todo.duedate %></span>
  <span class="todo-field completion"
    hx-put="/todo/<%= todo.id %>/toggle"
    hx-target="closest .todo-item"
    hx-swap="outerHTML"
    ><%= todo.completed ? "✅" : "⏳" %></span>
</div>

Notice the hx-target="closest .todo-item" hx-swap="outerHTML" that will find the parent and then replace the entire node with the returned content.

Also notice that I’m construction the URL to the HTTP PUT request using the id of the todo item (hx-put="/todo/<%= todo.id %>/toggle")

Here’s the backend code:

router.put("/:id/toggle", async (req, res) => {
  await toggleTodoCompleted(req.params.id);
  const todo = await getTodo(req.params.id);
  res.render("todo/todo-list-item.ejs", {todo});
});

(Don’t forget to import toggleTodoCompleted and getTodo from the todoService.js)

The :id construct is a way for Express to parse the querystring into req.params.id.

Delete Todo items

While we’re at it, lets create a delete feature. Its very much the same, here’s the backend:

import { deleteTodo } from "../lib/firebase/todoService.js";
const router = express.Router();
router.delete("/:id", async (req, res) => {
  await deleteTodo(req.params.id)
  res.send(); // No content
});

The correct way, according to REST principles, to respond to HTTP DEL is through No Content (status code 200 or 204, but I ran into problems returning 204). I’ve always wonder about that, but now it makes sense. Let’s replace this element itself with the empty response, in effect deleting the the element.

In the template I also show off the hx-confirm that will pop-up a confirmation box.

<div class="todo-item">
  <span class="todo-title"><%= todo.title %></span>
  <span class="todo-field"><%= todo.duedate %></span>
  <span
    class="todo-field todo-action"
    hx-put="/todo/<%= todo.id %>/toggle"
    hx-target="closest .todo-item"
    hx-swap="outerHTML"
    ><%= todo.completed ? "✅" : "⏳" %></span
  >
  <span
    class="todo-field todo-action"
    hx-confirm="Delete?! Sure about that?"
    hx-delete="/todo/<%= todo.id %>"
    hx-target="closest .todo-item"
    hx-swap="outerHTML"
    ></span
  >
</div>

Since the hx-target and hx-swap are the same for the two <spans> we can make use of the HTMx inheritance feature. All attributes are inherited from their parents, but can be overridden.

Here’s the cleaned up code:

<div
  class="todo-item"
  hx-target="closest .todo-item"
  hx-swap="outerHTML">
  <span class="todo-title"><%= todo.title %></span>
  <span class="todo-field"><%= todo.duedate %></span>
  <span
    class="todo-field todo-action"
    hx-put="/todo/<%= todo.id %>/toggle">
      <%= todo.completed ? "✅" : "⏳" %></span
  >
  <span
    class="todo-field todo-action"
    hx-confirm="Delete?! Sure about that?"
    hx-delete="/todo/<%= todo.id %>"
    ></span
  >
</div>

Nice! We’re getting close to done. Let’s make a Edit-feature too.

(Again - I added some styling here to better see that the icons are clickable - get your styles here)

Edit todo item

This is a bit trickier - we need a form to edit the item, and then a way to update the item using a HTTP PUT.

That’s two backend features ordered:

router.get("/:id/edit", async (req, res) => {
  const todo = await getTodo(req.params.id);
   res.render("todo/edit.ejs", {todo});
});

router.put("/:id", async (req, res) => {
  const postedTodo = req.body;
  await updateTodo(req.params.id, postedTodo);
  const todo = await getTodo(req.params.id);
  res.render("todo/todo-list-item.ejs", {todo});
});

First we update the todo-list-item.ejs to include a way to call the /todo/<id>/edit route and display the `edit.ejs´ form:

<div class="todo-item" hx-target="closest .todo-item" hx-swap="outerHTML">
  <span class="todo-title"><%= todo.title %></span>
  <span class="todo-field"><%= todo.duedate %></span>
  <span class="todo-field todo-action" hx-put="/todo/<%= todo.id %>/toggle"
    ><%= todo.completed ? "✅" : "⏳" %></span
  >
  <span
    class="todo-field todo-action"
    hx-get="/todo/<%= todo.id %>/edit"
    hx-swap="innerHTML"
    >...</span
  >
  <span
    class="todo-field todo-action"
    hx-confirm="Delete?! Sure about that?"
    hx-delete="/todo/<%= todo.id %>"
    ></span
  >
</div>

The edit.ejs file looks different enough from the new.ejs to call for a separate file, but it’s quite similar:

<form id="new-todo-form">
  <input type="text" name="title" required value="<%= todo.title %>" />
  <input type="date" name="duedate" required value="<%= todo.duedate %>" />
  <button
    type="submit"
    hx-put="/todo/<%= todo.id %>"
    hx-target="closest .todo-item"
    hx-swap="outerHTML"
  >Update</button>
</form>

Here we are making HTTP PUT request using hx-put="/todo/<%= todo.id %>" and update the information about the item. I did not include the Completion here, as there’s a separate function for that.

I had a problem here when I used the id attribute of the HTML elements. When sending a HTML form back (using in our case hx-put) we need to use the name-attributes on <input> elements. id are for using the JavaScript DOM.

Summary

That’s it for this post. We have built a fully-fledge (albeit missing some error handling and validation, I’m happy to admit) todo application, storing the information in a collection per logged in user.

The code will be update here.

I have one more thing I wanted to build and that is the counters at the end. It will require some HTMx trickery and I’ll explore some options on how to achieve that, and make some very lofty promises. But that is in the next post.

Twitter, Facebook