HTMX, or the forbidden black magic

With all the hype surrounding HTMX lately and its clever way to do something, we were already doing 10~15 years ago, in a way that simply looked like cheating, I quickly hopped on the train and off I go to learn some black magic on the way. adava kedavra

DHX

Stands for stop naming tech product by its acronym Deno HtmX

Sounds fun right ?

So that's it for the stack. As for the data persistency I'll stick to Deno's Web Storage API as their really promising KV database is under closed test.

Quick recap about HTMX for those who slept in class.

HTMX is offering a declarative syntax that lets you write app logic inside your markup. The reactivity is done through case-by-case replacement of HTML content received through ajax call to the server.

Foundation

To test the capabilities of HTMX I'll implement a basic TODO App.

Let's start by creating a server.ts file where we'll centralize the route handling.

1interface Routes {
2  [routeName: string]: (req: Request) => Response | Promise<Response>
3}
4function routeMatching(req: Request, routes: Routes) {
5  const {pathname} = new URL(req.url);
6  const route = routes[pathname] ?? (() => new Response("Too bad"))
7  return route(req)
8}

I'm first creating a handy route matching function that will be receiving a Routes Object.

1Deno.serve((req: Request) => routeMatching(req, {
2  '/': () => new Response('HELLO WORLD', {
3    headers: new Headers([["content-type","text/html"]])
4  }),
5  '/completed': (req: Request) => {},
6  '/delete': (req: Request) => {},
7  '/create': async (req: Request) => {}
8}));

Here we've set the 4 routes needed for our app. But for now, the only thing you'll see will be that big HELLO WORLD in tiny 16px font size on a white background. light mode

So let's take care of our UI. And we'll do that with the module deno_html that lets you write your views using ES6 string templating syntax.

So I'm gonna create a new file called index.html.ts

 1import { html } from "https://deno.land/x/html/mod.ts";
 2export default html`
 3  <!DOCTYPE html>
 4  <html lang="en">
 5  <head>
 6    <meta charset="UTF-8">
 7    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 8    <title>DHX</title>
 9    <script src="https://unpkg.com/htmx.org@1.9.3"></script>
10    <style>
11      @import url('https://fonts.googleapis.com/css2?family=Open+Sans&display=swap');
12      :root{
13        zoom: 150%; 
14        background: #242424;
15        color: white;
16        font-family: 'Open Sans'
17      }
18      .completed {
19        text-decoration:line-through;
20      }
21    </style>
22  </head>
23  <body>
24    <ul id="todolist"></ul>
25    <form>
26      <input type="text" name="todo-name">
27      <button type="submit">Create</button>
28    </form>
29  </body>
30  </html>
31`

Then let's update our server.ts index route handler method accordingly :

1import index from './index.html.ts';
2Deno.serve((req: Request) => routeMatching(req, {
3  '/': () => new Response(index, {
4    headers: new Headers([["content-type","text/html"]])
5  }),
6  ...
7}));

Here's our basic UI that looks like this : basic ui

Note that I've already included HTMX, a simple script tag is needed>

Creating a Todo

Here comes the fun part. We're actually really start using HTMX at this point.

Let's write the front-end logic first

 1<ul id="todolist"></ul>
 2<form >
 3  <input type="text" name="todo-name">
 4  <button 
 5    type="submit" 
 6    hx-post="/create" 
 7    hx-include="[name='todo-name']" 
 8    hx-target="#todolist" 
 9    hx-swap="beforeend"  
10  >Create</button>
11</form>

As you can see with this declarative syntax, everything is happening in the markup through the use of custom attributes that can be prefixed with data- if you prefer.

It reads as : On the click event of the button, do an ajax post request to /create that'll include the value of the input 'todo-name', then print out the response onto the #todolist HTML Element by using the insertAdjacentHtml('beforeend', response) method

Simple right ? HTMX abstracts what's would normally take like 10 to 20 line of code into only 4. Amazing.

As for the backend code, that's not so much complicated.

First I'm gonna create a new file todo.ts to hold our 'Database' manipulation code.

1export interface Todo {
2  id: number,
3  todoName: string
4  state: 'completed' | 'deleted' | 'created'
5}

Let's first define a simple Todo type.

1const todosRef = localStorage.getItem('todos');
2
3export const todos: Array<Todo> = todosRef === null 
4  ? (_ => {
5    localStorage.setItem('todos',JSON.stringify([]));
6    return []
7  })() 
8  : JSON.parse(localStorage.getItem('todos')!);

And here I define my array of todos read from the localStorage if it exists.

 1export function createTodo(todoName: Todo['todoName']) {
 2  const _todos: Array<Todo> = JSON.parse(localStorage.getItem('todos'));
 3
 4  const todo = {
 5    id: parseInt(`${_todos.length +1}`),
 6    todoName,
 7    state: 'created',
 8  } as Todo;
 9
10  _todos.push(todo);
11
12  localStorage.setItem('todos',JSON.stringify(_todos));
13
14  return todo;
15}
16
17export function completeTodo(id: Todo['id']) {}
18
19export function deleteTodo(id: Todo['id']) {}

And here I'm exporting 3 functions that we'll cover along the way.

For now the createTodo one as you might've guessed is simply re-setting the todos array to which we've added the newly created todo, to the localStorage.

Back to server.ts, we need to call this method to send back the html response we'll insert into our view.

1'/create': async (req: Request) => {
2  const form = await req.formData();
3  const todoName = await form.get('todo-name') as string;
4  const todo = createTodo(todoName);
5  return new Response(???);

Here, we're getting the value of the todo-name input sent by HTMX and calling our createTodo function. For the response we can easily figure that our, by creating a Todo component that will return the needed markup.

 1//index.html.ts
 2export function $Todo(todo: Todo) {
 3  return html`
 4    <li>  
 5      <span id="todo-${todo.id}">${todo.todoName}</span>
 6      <button name=${todo.id}>:OK_hand:</button>
 7      <button name=${todo.id}>:cross_mark:</button>   
 8    </li>
 9  `
10}
1//server.ts
2import index, { Todo } from './index.html.ts';
3...
4return new Response(Todo(todo));
5...

Now upon clicking the create button it should reflect like that on the ui dhx-created

We're still missing one thing though, that is displaying our todos after a page refresh. Simple enough :

1//index.html.ts
2import { todos } from "./todo.ts";
3<ul id="todolist">${todos.map(todo => $Todo(todo)).join('')}</ul>

Completing a todo

Okay now the second method, again let's focus on the frontend code first.

1<span id="todo-${todo.id}">${todo.todoName}</span>
2<button 
3  name=${todo.id}
4  hx-put="/completed"
5  hx-swap="none"
6>👌</button>

On the click event of the button, do an ajax put request to /completed that'll send the trigger target's name, then do nothing

As for the handling this request :

 1//todos.ts
 2export function completeTodo(id: Todo['id']) {
 3  const _todos: Array<Todo> = JSON.parse(localStorage.getItem('todos')!)
 4  .map((todo: Todo) => {
 5    if(todo.id === id) {
 6      return {
 7        ...todo,
 8        state: todo.state === 'created' ? 'completed' : 'created'
 9      }
10    } else return todo;
11  })!;
12
13  localStorage.setItem('todos',JSON.stringify(_todos));
14}
15
16//server.ts
17...
18'/completed': (req: Request) => {
19  const todoId = parseInt(req.headers.get('hx-trigger-name')!);
20  completeTodo(todoId)
21  return new Response('ok');
22},
23...

As you can see, HTMX will send out the infos we need (the attribute name of the button that triggers the call, whose value was todo.id) in the request Headers.

Ok, but we want to reflect back the updated state of our todo to our view, so let's bring a new member to the party : hyperscript. Hyperscript let's you write declarative JS code in the markup, useful for event handling. Like HTMX, only one script tag is enough to start using hyperscript

1<script src="https://unpkg.com/hyperscript.org@0.9.9"></script>

That perfectly suits our needs as HTMX will dispatch a bunch of custom events along the way, that we can simply take advantage of in hyperscript.

 1...
 2<style>
 3  .completed {
 4    text-decoration: line-through;
 5  }
 6</style>
 7...
 8<span id="todo-${todo.id}">${todo.todoName}</span>
 9<button 
10  ...
11  _="on htmx:afterRequest toggle .completed on 
12  #todo-${todo.id}"

would translate to something like

1button.addEventListener('html:afterRequest', () => todoRef.classList.toggle('.completed'))

But again, upon reloading the page, we'll lose track of the completed state, so let's write a simple classMap function.

 1function classMap(input: string) {
 2  return {
 3  'completed': 'completed'
 4  }[input] ?? ''
 5}
 6...
 7 `<span 
 8  class=${classMap(todo.state)}
 9  id="todo-${todo.id}"
10>${todo.todoName}</span>`

Delete

You know the way already

 1<li id="row-${todo.id}">
 2  ...
 3  <button
 4    name=${todo.id}
 5    hx-put="/delete"
 6    hx-swap="none"
 7    _="on htmx:afterRequest remove 
 8    #row-${todo.id}"
 9  >:cross_mark:</button>
10...
 1//server.ts
 2...
 3'/delete': (req: Request) => {
 4  const todoId = parseInt(req.headers.get('hx-trigger-name')!);
 5  deleteTodo(todoId);
 6  return new Response('ok');
 7},
 8...
 9//todo.ts
10export function deleteTodo(id: Todo['id']) {
11  let _todos: Array<Todo> = JSON.parse(localStorage.getItem('todos'));
12
13  _todos = _todos.filter((todo: Todo) => todo.id !== id);
14
15  localStorage.setItem('todos',JSON.stringify(_todos));
16}

Last thing we can add is this line of hyperscript to reset the input value after creating a new todo

1<form _="on htmx:afterRequest reset() me">

Voilà !

Only thing left would be to throw a fully customized UI using TailwindCSS at it and we're ready to call ourselves true HTML DEVELOPER.

Joke aside, I really see a future where we would only write frontend logic in the HTML directly.

Feel free to look at the code on my github. Bye :waving_hand: