Returning to roots: table updates with HTMX

Dec 31, 2020

I have recently been contemplating the value of modern web apps: how far we have strayed from the way websites used to be (and still often are) built, and the benefits and costs of doing so. I am concerned that in our move to single page apps (SPA), along with moving the job of rendering to the cient, that we have increased our development complexity (and therefore costs) significantly, for minimal advantages. Covering these concerns in more detail will need to wait for another blog post. Here, I want to get practical, and show an example of how we can do server side rendered html, combined with multiple pages, while still offering page updates without reloading the whole page, for a nicer user experience. We do not need to sacrifice everything when we move back to server side rendered html, combined with mulitple pages, and minimal javascript.

I came across HTMX recently, a very small (~9KB) javascript library designed to work with server side rendered content, and works well with multi-page web apps. For example (and the topic of this blog post), you can update portions of a page without requiring the browser client to reload the page, using whatever backend you please to produce the HTML. The beautiful thing about this setup is that you can build things in such a way that if someone has disabled javascript, the site still functions as expected. To me, that is of great value.

Therefore, I present an example of using HTMX with Go to build a simple table view with filters, that updates the table content when you change the filters, without reloading the page. Furthermore, if javascript is disabled (which you can verify by removing the HTMX library), the filters still work, by triggering a page load. The whole thing uses standard HTML. It’s dead easy to set up, and the lessons here can be translated across to whatever your favourite backend framework is.

package main

import (
	"html/template"
	"log"
	"net/http"
	"strconv"
	"strings"
	"time"
)

var tableHTML = `
{{define "table"}}
	<table id="res-list">
			<thead>
					<tr>
							<th>Name</th>
							<th>Active</th>
					</tr>
			</thead>
			<tbody>
					{{range .data}}
					<tr>
							<td>{{.Name}}</td>
							<td>{{.Active}}</td>
					</tr>
					{{end}}
			</tbody>
	</table>
{{end}}
{{if .tableOnly}}
	{{template "table" .}}
{{else}}
<html>
<head>
	<title>Example table</title>
</head>
<body>
	<form id="filters" action="/" method="GET" hx-get="/" hx-target="#res-list" hx-include="#filters" hx-push-url="true" hx-swap="outerHTML">
		<div>
			<input name="filter.name" type="text" placeholder="Text input" {{if .filter.Name}}value="{{.filter.Name}}"{{end}}>
		</div>
		<div>
			<label>Active</label>
			<label>
				<input type="radio" name="filter.active" value="true" {{if eq .filter.Active "true"}}checked{{end}}>
				Yes
			</label>
			<label>
				<input type="radio" name="filter.active" value="false" {{if eq .filter.Active "false"}}checked{{end}}>
				No
			</label>
			<label>
				<input type="radio" name="filter.active" value="" {{if eq .filter.Active "" "checked"}}checked{{end}}>
				Any
			</label>
		</div>
		<div>
			<a href="/">Reset filters</a>
			<button type="submit">Search</button>
		</div>
	</form>
	<p>Built: {{.built}}</p>
	{{template "table" .}}
	<script src="https://unpkg.com/htmx.org@1.0.2"></script>
</body>
</html>
{{end}}
`

type Data struct {
	Name   string
	Active bool
}

var data = []Data{
	{"Harry", false},
	{"Sue", true},
	{"Fred", true},
	{"Jane", false},
}

type ListFilter struct {
	Name   string
	Active string
}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		var l ListFilter
		t, err := template.New("T").Parse(tableHTML)
		if err != nil {
			panic(err)
		}

		l.Name = r.URL.Query().Get("filter.name")
		l.Active = r.URL.Query().Get("filter.active")

		payload := map[string]interface{}{
			"data":   filterData(data, l),
			"filter": l,
			"built":  time.Now().Format("2006-01-02 15:04:05"),
		}

		// Looks like a HTMX request and matches our trigger name, so let's proceed
		if r.Header.Get("HX-Request") == "true" && r.Header.Get("HX-Trigger") == "filters" {
			payload["tableOnly"] = true
		}

		err = t.ExecuteTemplate(w, "T", payload)
		if err != nil {
			panic(err)
		}
	})

	log.Printf("Starting on 8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func filterData(data []Data, filter ListFilter) (res []Data) {
	active, _ := strconv.ParseBool(filter.Active)
	name := strings.ToLower(filter.Name)
	for _, datum := range data {
		if (len(name) == 0 || strings.Contains(strings.ToLower(datum.Name), name)) &&
			(len(filter.Active) == 0 || (datum.Active == active)) {
			res = append(res, datum)
		}
	}

	return
}