💾 Archived View for capsule.adrianhesketh.com › 2021 › 06 › 04 › hotwired-go-with-templ captured on 2024-05-12 at 15:03:38. Gemini links have been rewritten to link to archived content

View Raw

More Information

⬅️ Previous capture (2021-11-30)

-=-=-=-=-=-=-

capsule.adrianhesketh.com

home

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.

[0]

[1]

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

[2]

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.

Screenshot showing basic layout

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.

[3]

	// 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.

[4]

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.

todo.gif

More

Next

Using AWS CDK with Go to launch an app with App Runner

Previous

templ - hot reload with air

Home

home