adrianhesketh.com

Building a Hotwired web app with Go and Templ

Hotwire [0] is an approach used to build Web applications without using a lot of front-end JavaScript. Most of the content is served as HTML via server-side rendered templates, which makes it an ideal partner for the templ [1] templating language. In fact, I designed templ with exactly this scenario in mind.

I tried out building a really simple app [2] so I could learn the mechanics.

The project has two web servers in it. One that listens at http://localhost:8000 in the cmd directory and another one that listens at http://localhost:8001 in the remote-frame directory. I’ll cover remote-frame in another post.

Main web server

The main.go file in the cmd directory starts up the main Web server. It configures the / route.

func main() {
	// Wire up the routes.
	http.Handle("/", IndexHandler{})
	// Use localhost:8000 rather than :8000 so MacOS doesn't ask if you want to accept incoming connections.
	fmt.Println("Listening on http://localhost:8000")
	err := http.ListenAndServe("localhost:8000", nil)
	if err != nil {
		fmt.Println("Error:", err)
	}
}

The IndexHandler{} serves up traffic when HTTP requests hit http://localhost:8000/. It handles GET and POST requests by inspecting the request parameter (r), then calls the Get or Post method on itself depending on the HTTP request method used.

type IndexHandler struct{}

func (h IndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		h.Get(w, r)
		return
	case http.MethodPost:
		h.Post(w, r)
		return
	}
	http.Error(w, "unhandled verb", http.StatusBadRequest)
}

GET /

The Get method uses the todo.DB “database” code to list out the todos, constructs an instance of IndexViewData and passes it to the Render method that’s also a part of the IndexHandler.

IndexViewData is a type that defines all of the “data” used by the Index “view”.

func (h IndexHandler) Get(w http.ResponseWriter, r *http.Request) {
	todos, err := todo.DB{}.List()
	if err != nil {
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	d := templates.IndexViewData{
		Todos:   todos,
		NewTodo: templates.NewTodoViewData{},
	}
	h.Render(d, w, r)
}

The Render method takes the IndexViewData, and passes it to templ templates to render HTML to the client:

func (h IndexHandler) Render(d templates.IndexViewData, w http.ResponseWriter, r *http.Request) {
	// Create the templates.
	body := templates.Index(d)
	page := templates.Page("Todos", body)

	// Render.
	err := page.Render(r.Context(), w)
	if err != nil {
		log.Println("error", err)
	}
}

Let’s look at how those templates are constructed.

Templates

The templ templates are stored in the templates directory. Since templ files get converted into Go code, they can sit alongside other Go code and use Go types defined in the same package and in imported packages.

todo.go

There’s one main template for this application todo.templ, but alongside it is todo.go which contains structs that define the structure of data that the templates will render out to HTML.

package templates

import todo "github.com/a-h/go-hotwire-todo"

// The index page.
type IndexViewData struct {
	Todos   []*todo.Todo
	NewTodo NewTodoViewData
}

type NewTodoViewData struct {
	Text string `schema:"text"`
	// This can be done with a required attribute in HTML, so there may be no need for this, it's just an example.
	TextValidation string
}

func (d *NewTodoViewData) Validate() (isValid bool) {
	isValid = true
	if d.Text == "" {
		d.TextValidation = "Text cannot be empty."
		isValid = false
	}
	return
}

todo.templ

The todo.templ template renders the content of the app.

Like all templ files, todo.templ starts with the name of the package, and any imports that are required.

{% package templates %}

{% import todo "github.com/a-h/go-hotwire-todo" %}

Page

The Page template defines the overall layout of a page. When templ generate is ran, this template gets compiled into a Go file called todo_templ.go containing a function called Page. The Page function takes a title parameter that’s used as the title for the page, and a content parameter which forms the body of the page, and returns a templ.Component that can be used to render HTML to any io.Stream, e.g. a HTTP response or a string buffer.

One thing to note is that the content parameter is a templ.Component. This content parameter gets rendered inside the HTML body tag using the {%! content %} syntax.

You might also notice our first glimpse of Hotwire - a script tag that brings in the Turbo library. This app doesn’t actually require JavaScript to work, but if JavaScript is enabled, it uses Turbo to avoid carrying out full screen refreshes.

{% templ Page(title string, content templ.Component) %}
	<html>
		<head>
			<title>{%= title %}</title>
			<script src="https://unpkg.com/@hotwired/turbo@7.0.0-beta.5/dist/turbo.es5-umd.js"></script>
		</head>
		<body>
			{%! content %}
		</body>
	</html>
{% endtempl %}

Index

If you refer back to how GET / is handled, you’ll see that the result of templates.Index is passed to the Page template as the body - i.e. the Index template becomes the contents of the <body> element.

	body := templates.Index(d)
	page := templates.Page("Todos", body)

The Index template is used as the contents of the <body> element, and contains references to other templates, using the Todos template to display a list of todo items, and the NewTodo template to display a form that can be used to create new Todo items.

{% templ Index(d IndexViewData) %}
	<h1>{%= "Todos" %}</h1>
	{%! Todos(d.Todos) %}
	<h1>{%= "Create" %}</h1>
	{%! NewTodo(d.NewTodo) %}
{% endtempl %}

Todos

The Turbo JavaScript library imported in the Page template can rewrite sections of the HTML code without doing a full refresh of the page.

The Turbo library uses the turbo-frame elements to know which sections of the screen can be updated, and the id attribute of the turbo-frame to know which element to update when it receives an turbo-stream response from the server (more on that later).

{% templ Todos(todos []*todo.Todo) %}
	<turbo-frame id="todos">
		{% for _, t := range todos %}
			{%! Todo(t) %}
		{% endfor %}
	</turbo-frame>
{% endtempl %}

{% templ Todo(t *todo.Todo) %}
	<div>
		<div>{%= t.Item %}</div>
	</div>
{% endtempl %}

NewTodo

Finally, the NewTodo template renders a HTML form. The form is also within a turbo-frame which allows the NewTodo form to be updated without a full screen refresh using Turbo.

Note how the name attribute of the input is set to “text”. We’ll see how that gets matched up with the Text field on the NewTodoViewData type when we process the form submission.

Hopefully, you can spot that if the d.TextValidation field isn’t empty, then a validation message will be shown.

{% templ NewTodo(d NewTodoViewData) %}
	<turbo-frame id="new_todo">
		<form action="/" method="post">
			<div>
				<input type="text" name="text" value={%= d.Text %} />
			</div>
			{% if d.TextValidation != "" %}
				<div style="color: red">
					{%= d.TextValidation %}
				</div>
			{% endif %}
			<div>
				<input type="submit" value="New"/>
			</div>
		</form>
	</turbo-frame>
{% endtempl %}

Altogether, we’ve got a simple HTML page rendered, and we’ve got Turbo running on the page.

POST /

While it’s nice to be able to render GET requests to the server, the NewTodo template is rendering a HTML form on the screen. Once we’ve entered some text and clicked on the “New” button, the app should react to that and create a new “Todo” item.

The Turbo JavaScript library looks for forms that are within a turbo-frame and intercepts and rewrites the form posts but we still need to wire up receiving the form data.

This is done in cmd/main.go in the Post method receiver.

First, the code parses the HTTP form post.

func (h IndexHandler) Post(w http.ResponseWriter, r *http.Request) {
	// Parse the form.
	err := r.ParseForm()
	if err != nil {
		http.Error(w, "failed to parse form post", http.StatusBadRequest)
		return
	}

Next, it constructs a NewTodoViewData by decoding the form post from the HTTP request and mapping the form post values into the appropriate fields on the struct (e.g. putting the “text” form value into the Text field).

It does this using the Gorilla schema library [3], which is instantiated in the same file.

	// Populate the structs.
	ntvd := new(templates.NewTodoViewData)
	err = decoder.Decode(ntvd, r.PostForm)
	if err != nil {
		http.Error(w, "failed to decode form post", http.StatusBadRequest)
		return
	}

With the NewTodoViewData populated from the HTTP form post data, validation can take place. If there’s no problem, the new todo is created, and the NewTodoViewData gets cleared.

	// Validate and carry out actions.
	isValid := ntvd.Validate()
	var newTodo *todo.Todo
	if isValid {
		// Update the data.
		newTodo = &todo.Todo{
			ID:   uuid.New().String(),
			Item: ntvd.Text,
		}
		todo.DB{}.Upsert(newTodo.ID, newTodo.Item, newTodo.Complete)
		// Clear the form.
		ntvd = new(templates.NewTodoViewData)
	}

One of the nice parts of this design is that it works even if JavaScript is disabled on the client.

Requests that come from the Turbo library include a HTTP accept header that can be used to distinguish between the two worlds, so the code just checks if the request came from the Turbo library and if it didn’t, renders the whole screen again.

	if !IsTurboRequest(r) {
		// Get the view ready.
		todos, err := todo.DB{}.List()
		if err != nil {
			http.Error(w, "internal server error", http.StatusInternalServerError)
			return
		}
		d := templates.IndexViewData{
			Todos:   todos,
			NewTodo: *ntvd,
		}
		h.Render(d, w, r)
		return
	}

If it is a Turbo Frame request, the Handler can return a Turbo Stream [4] of updates instead.

Turbo uses the list of turbo-stream elements returned by the handler to update, append or remove sections of the screen. This allows us to skip carrying out a database query and just append the new todo item to the list on screen using the append action, and to re-render the form using the update action to take into account the validation messages, or to clear the form.

	// If it's a Turbo request, we can just update the bits of the screen we need to.
	var actions []Action

	// Update the todo list.
	if newTodo != nil {
		actions = append(actions, StreamAction(ActionAppend, "todos", templates.Todo(newTodo)))
	}

	// Update the form.
	actions = append(actions, StreamAction(ActionUpdate, "new_todo", templates.NewTodo(*ntvd)))

	// Return the stream of updates.
	TurboStream(actions...).ServeHTTP(w, r)
}

Turbo streams utilities

Since Turbo streams are a list of turbo-stream elements, we need a way to make them, so I created the Action template in templates/turbo.templ.

{% package templates %}

{% templ Action(action string, target string, template templ.Component) %}
	<turbo-stream action={%= action %} target={%= target %}>
		<template>
			{%! template %}
		</template>
	</turbo-stream>
{% endtempl %}

I also made some utility functions to make it easy to return turbo-stream elements. I left them all in main.go as a demo, but as I get confident that I’m going to stick with design, I’m likely to create templ-hotwire package.

The utilities start with an enumeration of all the possible Turbo Frame actions that you can do.

type ActionType string

const (
	ActionAppend  ActionType = "append"
	ActionPrepend            = "prepend"
	ActionReplace            = "replace"
	ActionUpdate             = "update"
	ActionRemove             = "remove"
)

Then a definition of the data required by the turbo-stream element, which includes the action that will be taken (“append” / “prepend” etc.) on the Target (based on its HTML ID), and the Template content that will be used to do it.

type Action struct {
	Type     ActionType
	Target   string
	Template templ.Component
}

func StreamAction(at ActionType, target string, template templ.Component) Action {
	return Action{Type: at, Target: target, Template: template}
}

There’s also a HTTP handler that takes all of the actions and renders them out to the HTTP response, using the appropriate Content-Type header.

func TurboStream(actions ...Action) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/vnd.turbo-stream.html")
		for _, action := range actions {
			action := action
			templates.Action(string(action.Type), action.Target, action.Template).Render(r.Context(), w)
		}
	})
}

And a function that checks that whether the incoming request came from Turbo or not.

func IsTurboRequest(r *http.Request) bool {
	return strings.Contains(r.Header.Get("accept"), "text/vnd.turbo-stream.html")
}

Bringing it all together

With that in place, we’ve got a database driven application with partial page updates without writing any JavaScript, or relying on a complex front-end framework like React.

It’s server-side rendered, which provides good search engine optimisation and fast initial page load, and it even works without JavaScript.