Checking out htmx

Stefan Alfbo - Jul 13 '23 - - Dev Community

There has been some buzz at least in my twitter feed around htmx and is one project in the GitHub Accelerator cohort, so it's time to dip my toes into this library.

Here is a short introduction from their documentation page;

htmx is a library that allows you to access modern browser features directly from HTML, rather than using javascript.

I will try to convert the tutorial: Tic-Tac-Toe from Reacts documentation to use htmx instead.

Lets begin with initializing a project for this and open it up in VS Code.

mkdir tic-tac-toe && cd tic-tac-toe
git init
npm init -y
touch .gitignore
code .
Enter fullscreen mode Exit fullscreen mode

Ignore the node_modules-folder by adding it to the .gitignore-file.

/node_modules
Enter fullscreen mode Exit fullscreen mode

I will use Express.js with TypeScript as a backend, therefore we will need to add some npm packages:

npm install typescript express --save-dev
npm install @types/node @types/express --save-dev
Enter fullscreen mode Exit fullscreen mode

Create a tsconfig.json like this:

npx tsc --init
Enter fullscreen mode Exit fullscreen mode

and use another output directory for the artifacts from the compilation.

{
  "compilerOptions": {
    // ...
    // ... find the outDir property in the tsconfig
    // ... and change it to this
    "outDir": "./dist",
    // ...
    // ...
    // ...
    "skipLibCheck": true /* Skip type checking all .d.ts files. */
  }
}
Enter fullscreen mode Exit fullscreen mode

Setup the backend server code:

mkdir src && touch src/index.ts
Enter fullscreen mode Exit fullscreen mode

add the setup code for Express including a root route (/) in the index.ts-file.

import express, { Express, Request, Response } from 'express';

const app: Express = express();
const port = 3000;

app.get('/', (req: Request, res: Response) => {
  res.send('Tic-Tac-Toe backend');
});

app.listen(port, () => {
  console.log(`🦄[backend]: Server is running at http://localhost:${port}`);
});
Enter fullscreen mode Exit fullscreen mode

We need to add a script to the package.json-file to make it easy to start the backend.

  "scripts": {
    "dev": "npx tsc && node ./dist/index.js"
  },
Enter fullscreen mode Exit fullscreen mode

and add the dist-folder to .gitignore.

/node_modules
/dist
Enter fullscreen mode Exit fullscreen mode

Finally we can make a check point to run the backend and visit it at http://localhost:3000/ to see a Tic-Tac-Toe message.

npm run dev
Enter fullscreen mode Exit fullscreen mode

Looks good so far, lets change the root route to deliver a html page instead.

import express, { Express, Request, Response } from 'express';
import path from 'path';

const app: Express = express();
const port = 3000;

app.get('/', (req: Request, res: Response) => {
  // The change is here plus the import of path
  res.sendFile(path.join(__dirname, '../src/public/index.html'));
});

app.listen(port, () => {
  console.log(`🦄[backend]: Server is running at http://localhost:${port}`);
});
Enter fullscreen mode Exit fullscreen mode

and now the html file, index.html.

mkdir src/public && touch src/public/index.html
Enter fullscreen mode Exit fullscreen mode

and add the following code.

<!DOCTYPE html>
<html lang="en-US">
<head>
    <meta charset="UTF-8">
    <title>Tic-Tac-Toe</title>
</head>
<body>
    <h1>Tic-Tac-Toe</h1>
</body>
Enter fullscreen mode Exit fullscreen mode

I think we got everything at place to start to tinkering with htmx now.

Start with adding htmx via a CDN (since it is not a production project, reasons to avoid Javascript CDNs) in index.html-file header.

<script src="https://unpkg.com/htmx.org@1.9.2" integrity="sha384-L6OqL9pRWyyFU3+/bjdSri+iIphTN/bvYyM37tICVyOJkWZLpP2vGn6VUEXgzg6h" crossorigin="anonymous"></script>
Enter fullscreen mode Exit fullscreen mode

Copy the css from the Tic-Tac-Toe tutorial and add it to a styles.css-file.

touch src/public/styles.css
Enter fullscreen mode Exit fullscreen mode

and the css code.

* {
    box-sizing: border-box;
  }

  body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
  }

  h1 {
    margin-top: 0;
    font-size: 22px;
  }

  h2 {
    margin-top: 0;
    font-size: 20px;
  }

  h3 {
    margin-top: 0;
    font-size: 18px;
  }

  h4 {
    margin-top: 0;
    font-size: 16px;
  }

  h5 {
    margin-top: 0;
    font-size: 14px;
  }

  h6 {
    margin-top: 0;
    font-size: 12px;
  }

  code {
    font-size: 1.2em;
  }

  ul {
    padding-left: 20px;
  }

  * {
    box-sizing: border-box;
  }

  body {
    font-family: sans-serif;
    margin: 20px;
    padding: 0;
  }

  .square {
    background: #fff;
    border: 1px solid #999;
    float: left;
    font-size: 24px;
    font-weight: bold;
    line-height: 34px;
    height: 34px;
    margin-right: -1px;
    margin-top: -1px;
    padding: 0;
    text-align: center;
    width: 34px;
  }

  .board-row:after {
    clear: both;
    content: '';
    display: table;
  }

  .status {
    margin-bottom: 10px;
  }
  .game {
    display: flex;
    flex-direction: row;
  }

  .game-info {
    margin-left: 20px;
  }
Enter fullscreen mode Exit fullscreen mode

Add the a link to css file in index.html-file.

<!DOCTYPE html>
<html lang="en-US">
<head>
    <script src="https://unpkg.com/htmx.org@1.9.2" integrity="sha384-L6OqL9pRWyyFU3+/bjdSri+iIphTN/bvYyM37tICVyOJkWZLpP2vGn6VUEXgzg6h" crossorigin="anonymous"></script>
    <meta charset="UTF-8">
    <link href="styles.css" rel="stylesheet" />
    <title>Tic-Tac-Toe</title>
</head>
<body>
    <h1>Tic-Tac-Toe</h1>
</body>
Enter fullscreen mode Exit fullscreen mode

We need to do some changes to the backend so it can serve the css file by adding the public-folder as a static resource in Express.

// right after the line: const port = 3000;
app.use(express.static(path.join(__dirname, '../src/public')));
Enter fullscreen mode Exit fullscreen mode

Now it's time to update the body of the index.html-file to include the game board and some htmx magic.

<!DOCTYPE html>
<html lang="en-US">
<head>
    <script src="https://unpkg.com/htmx.org@1.9.2" integrity="sha384-L6OqL9pRWyyFU3+/bjdSri+iIphTN/bvYyM37tICVyOJkWZLpP2vGn6VUEXgzg6h" crossorigin="anonymous"></script>
    <script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
    <meta charset="UTF-8">
    <link href="styles.css" rel="stylesheet" />
    <title>Tic-Tac-Toe</title>
</head>
<body>
    <h1>Tic-Tac-Toe</h1>
    <div class="status" hx-ext="sse" sse-connect="/status" sse-swap="message"></div>
    <div class="board-row">
      <button class="square" hx-get="/move?pos=0" hx-swap="innerHTML"></button>
      <button class="square" hx-get="/move?pos=1" hx-swap="innerHTML"></button>
      <button class="square" hx-get="/move?pos=2" hx-swap="innerHTML"></button>
    </div>
    <div class="board-row">
      <button class="square" hx-get="/move?pos=3" hx-swap="innerHTML"></button>
      <button class="square" hx-get="/move?pos=4" hx-swap="innerHTML"></button>
      <button class="square" hx-get="/move?pos=5" hx-swap="innerHTML"></button>
    </div>
    <div class="board-row">
      <button class="square" hx-get="/move?pos=6" hx-swap="innerHTML"></button>
      <button class="square" hx-get="/move?pos=7" hx-swap="innerHTML"></button>
      <button class="square" hx-get="/move?pos=8" hx-swap="innerHTML"></button>
    </div>
</body>
Enter fullscreen mode Exit fullscreen mode

The html layout is the same as the React version, however here we have added some hx-<attribute>, example

<button class="square" hx-get="/move?pos=0" hx-swap="innerHTML"></button>
Enter fullscreen mode Exit fullscreen mode
  • hx-get - make a http get request to /move?pos=0
  • hx-swap - will replace the inner html of the button with the response from the get request

Note that with htmx the response from the server side usually is HTML and not JSON. The reason behind this is explained in the documentation;

This keeps you firmly within the original web programming model, using Hypertext As The Engine Of Application State without even needing to really understand that concept.

If you look at the status div element there is another feature from htmx, server side events. This is enabled by an extension and is an extra library that is imported with a script tag

<script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
Enter fullscreen mode Exit fullscreen mode

and this is how it is used in this example.

<div class="status" hx-ext="sse" sse-connect="/status" sse-swap="message"></div>
Enter fullscreen mode Exit fullscreen mode
  • hx-ext - use the sse extension here
  • sse-connect - the URL to the SSE endpoint on the backend
  • sse-swap - use the data from the type message to replace the content of the div element

message is the default type for an unnamed event.

There is of course some backend code needed for keeping state, user interaction and handle the server side events. This is the spaghetti code for that 😆

import express, { Express, Request, Response } from 'express';
import path from 'path';

const app: Express = express();
const port = 3000;

type Client = {
  id: number;
  res: Response;
}

let clients: Client[] = [];

type Player = 'X' | 'O' | '';

const initializeGame = () => {
  app.locals.board = [
    '', '', '',
    '', '', '',
    '', '', ''
  ] as Player[];
  app.locals.nextPlayer = 'X' as Player;
  app.locals.status = 'Next player: X';
}

const switchPlayer = () => {
  app.locals.nextPlayer = app.locals.nextPlayer === 'X' ? 'O' : 'X';
}

const isWinner = (currentBoard: Player[]): boolean => {
  const winnerLines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];
  for (let i = 0; i < winnerLines.length; i++) {
    const [a, b, c] = winnerLines[i];
    if (currentBoard[a] && currentBoard[a] === currentBoard[b] && currentBoard[a] === currentBoard[c]) {
      return true;
    }
  }
  return false;
}

const sendStatusEvent = (status: string) => {
  clients.forEach(client => client.res.write(`data: ${JSON.stringify(status)}\n\n`))
}

initializeGame();

app.use(express.static(path.join(__dirname, '../src/public')));

app.get('/', (req: Request, res: Response) => {
  res.sendFile(path.join(__dirname, '../src/public/index.html'));
});

app.get('/move', (req: Request, res: Response) => {
  const boardIndex = parseInt(req.query.pos as string);
  const moveByPlayer = app.locals.nextPlayer;

  if (app.locals.board[boardIndex] !== '') {
    res.send(app.locals.board[boardIndex])
    return;
  }

  if (isWinner(app.locals.board)) {
    return;
  }

  app.locals.board[boardIndex] = moveByPlayer;
  switchPlayer();

  if (isWinner(app.locals.board)) {
    app.locals.status = `Winner: ${moveByPlayer}`;
  } else {
    app.locals.status = `Next player: ${app.locals.nextPlayer}`;
  }  
  sendStatusEvent(app.locals.status);
  res.send(moveByPlayer);
});

app.get('/status', (req: Request, res: Response) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.flushHeaders();

  clients.push({
    id: Date.now(),
    res,
  });

  sendStatusEvent(app.locals.status);
});

app.listen(port, () => {
  console.log(`🦄[backend]: Server is running at http://localhost:${port}`);
});
Enter fullscreen mode Exit fullscreen mode

Most of the things is happening in the endpoints move and status. As you can see we are just sending back X or O in the response in the move endpoint.

res.send(moveByPlayer);
Enter fullscreen mode Exit fullscreen mode

Which htmx will use to replace the text for the clicked button.

The sendStatusEvent function is where we send an update of status to the client.

const sendStatusEvent = (status: string) => {
  clients.forEach(client => client.res.write(`data: ${JSON.stringify(status)}\n\n`))
}
Enter fullscreen mode Exit fullscreen mode

Summary

It was fun to play with htmx, but maybe this tac-tac-toe game was not the best first application to try htmx with. However it was really easy to get started with htmx and most time was spended on the backend. The final project can be found on GitHub

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .