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 .
Ignore the node_modules
-folder by adding it to the .gitignore-file.
/node_modules
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
Create a tsconfig.json
like this:
npx tsc --init
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. */
}
}
Setup the backend server code:
mkdir src && touch src/index.ts
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}`);
});
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"
},
and add the dist
-folder to .gitignore.
/node_modules
/dist
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
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}`);
});
and now the html file, index.html
.
mkdir src/public && touch src/public/index.html
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>
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>
Copy the css from the Tic-Tac-Toe tutorial and add it to a styles.css
-file.
touch src/public/styles.css
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;
}
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>
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')));
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>
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>
- 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>
and this is how it is used in this example.
<div class="status" hx-ext="sse" sse-connect="/status" sse-swap="message"></div>
- 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}`);
});
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);
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`))
}
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