Implementing React-like Composition using Go's html/template
React popularized component based frontend development and newer frameworks like Flutter, SwiftUI etc have followed the same model. I feel this model provides really good ergonomics and it’s hard to go back to the older ways of building frontends.
The problem with React is that it comes with the modern JS baggage - everything feels unnecessarily complex and bloated, and I say that as someone who has worked with JS and frontend for the past 15 years!
It’s no wonder I feel drawn to Go with its “batteries-included” design and no-BS philosophy.
But is it possible to get React like ergonomics in Go? Can we have our cake and eat it too? The answer is yes, and we can achieve all this using just the standard library.
If you’re too excited to see how it looks like, here’s a demo app built using this approach.
Framing the problem
We want to be able to split the UI into smaller components like Button
, Dropdown
, FormInput
etc, then we compose them into bigger components like Navbar
, LoginForm
, NewUserForm
etc, and then compose them further into even bigger page-level components like HomePage
, LoginPage
, and so on.
At the same time, we want these components to be reusable across the codebase, be configurable so they can have different variants and have the higher level components abstract away the lower details.
Let’s use an example, here’s how we want the frontend to look like visually:
How the component tree would look like:
We can see that the two pages share most of the components with some variations. Components like Navbar
should be able to abstract away its child components.
Implementation using html/template
Lets define a low-level component, Button
:
// components/button.html
{{define "button"}}
<button class="button-container">
<a
class="button {{if .IsPrimary}}primary{{end}}"
{{if eq .IsSubmit true}}
type="submit"
{{else}}
href="{{.Link}}"
{{end}}>
{{.Text}}
</a>
</button>
{{end}}
// components/button.go
package components
type Button struct {
Text string
Link string
IsSubmit bool
IsPrimary bool
}
Let’s define the Navbar
:
// components/navbar.html
{{define "navbar"}}
<div class="navbar">
<a class="logo" href="/">My App</a>
<div class="actions">
{{template "dropdown" .ProjectsDropdown}}
{{template "button" .SettingsButton}}
</div>
</div>
{{end}}
// components/navbar.go
package components
type Navbar struct {
ProjectsDropdown Dropdown
SettingsButton Button
}
As you can see, we’ve used define
to define the components and template
to compose smaller components within larger ones.
Here’s how we would use the above inside LoginPage
:
// features/login/login.html
<!DOCTYPE html>
<html lang="en">
{{template "head"}}
<body>
{{template "navbar" .Navbar}}
<div class="section">
<div class="title">Login</div>
<form action="/login" method="post">
{{template "input" .EmailInput}}
{{template "input" .PasswordInput}}
{{template "button" .SubmitButton}}
</form>
</div>
</body>
</html>
// features/login/login.go
// ...
func renderLoginForm(w http.ResponseWriter, email string, emailError string, passwordError string) {
type templateData struct {
Navbar components.Navbar
EmailInput components.Input
PasswordInput components.Input
SubmitButton components.Button
}
tmplData := templateData{
Navbar: components.Navbar{
SettingsButton: components.Button{
Text: "Settings",
Link: "/settings",
IsSubmit: false,
},
ProjectsDropdown: // ...
},
EmailInput: components.Input{
ID: "email",
Label: "Email",
Type: "email",
Placeholder: "Enter your email address",
Error: emailError,
Value: email,
},
PasswordInput: components.Input{
ID: "password",
Label: "Password",
Type: "password",
Placeholder: "Enter your password",
Error: passwordError,
},
SubmitButton: components.Button{
Text: "Login",
IsSubmit: true,
IsPrimary: true,
},
}
templates.Render(w, "login.html", tmplData)
}
// ...
Ignore the templates.Render
part for the time being, I’ll explain it later.
You can see that we prep the data needed for rendering as a nested struct tmplData
and then pass it to the template. The template which is already composed of multiple components splits tmplData
and passes them to the relevant components.
If you’ve worked with React before, you can start seeing similarities in the way things are organized.
Notice how each page is also the root component, so we’ve multiple roots. I initially used the “template slots” pattern where there’s a single root template and others like MainContent
, Navbar
, Sidebar
etc can “slot” into the placeholders, but I found it confusing and hard to reason about. The drawback with multiple roots approach is you end up repeating the DOCTYPE
, html
, and body
tags, but I feel it’s managable. YMMV.
Abstracting away details
We can repeat the same across multiple pages, but if we look at the UI design mockups above, the Navbar
component should show the Button
and Dropdown
only in certain pages. And even in the pages where they’re shown, we don’t want to keep repeating this piece of code:
// features/login/login.go
// ...
tmplData := templateData{
Navbar: components.Navbar{
SettingsButton: components.Button{
Text: "Settings",
Link: "/settings",
IsSubmit: false,
},
ProjectsDropdown: // ...
},
// ...
This code and whether to show/hide the Button
and Dropdown
is better abstracted away. We can do this by creating a constructor that takes in an arg to toggle the Navbar behavior:
// components/navbar.go
package components
type Navbar struct {
+ ShouldShowActions bool
ProjectsDropdown Dropdown
SettingsButton Button
}
+func NewNavbar(ShouldShowActions bool) Navbar {
+ return Navbar{
+ ShouldShowActions: ShouldShowActions,
+ SettingsButton: Button{Text: "Settings", Icon: "gear", Link: "/settings", IsSubmit: false},
+ }
+}
// components/navbar.html
{{define "navbar"}}
<div class="navbar">
<a class="logo" href="/">My App</a>
+ {{if .ShouldShowActions}}
<div class="actions">
{{template "dropdown" .ProjectsDropdown}}
{{template "button" .SettingsButton}}
</div>
+ {{end}}
</div>
{{end}}
We can now update LoginPage
:
// features/login/login.go
// ...
tmplData := templateData{
- Navbar: components.Navbar{
- SettingsButton: components.Button{
- Text: "Settings",
- Link: "/settings",
- IsSubmit: false,
- },
- ProjectsDropdown: // ...
- },
+ Navbar: components.NewNavbar(false),
// ...
State management
So far, we’ve seen how to compose components and pass data from one layer to the ones below. This is sufficient for simple applications, but if you’re building something like a dashboard, then we require state management.
Let’s say your dashboard has a project selector and a table with pagination, we would have these requirements:
- If no project is selected, we need to set default values
- If the user clicks to see the next set of rows in a table, the project selector should not change
- If project is changed, the table state should be reset
Basically interacting with some components should only affect itself, whereas interacting with others can affect multiple components.
We can use the OG solution for state management - URL query params. Here’s how the logic would look like:
// ...
func HandleDashboardPage(w http.ResponseWriter, r *http.Request) {
selectedProjectID := r.URL.Query().Get("project_id")
tableOffset := r.URL.Query().Get("table_offset")
// Set to default values
if state.selectedProjectID == "" {
newURL := fmt.Sprintf("/dashboard?project_id=%s&table_offset=%d", projects[0].ProjectID, 0)
http.Redirect(w, r, newURL, http.StatusSeeOther)
return
}
renderDashboardPage(w, selectedProjectID, tableOffset)
}
// ...
We parse the query params to see if the state is already set, otherwise we initialize it to default values. We then construct the links inside the table pagination to point to the new state.
// ...
func renderDashboardPage(w http.ResponseWriter, projectID string, tableOffset string) {
type templateData struct {
Navbar components.Navbar
Table components.Table
}
navbar := getNavbar(projectID)
table := getTable(projectID, tableOffset)
tmplData := templateData{
Navbar: navbar,
Table: table,
}
templates.Render(w, "dashboard.html", tmplData)
}
func getTable(projectID string, offset string) components.Table {
var records []Record
limit := 10
offset, err := strconv.Atoi(offset)
if err != nil {
offset = 0
}
table := components.Table{
Records: records,
ShouldShowPagination: false,
Pagination: components.Pagination{
PageStartRecord: offset + 1,
PageEndRecord: 0,
TotalRecords: 0,
PrevLink: "",
NextLink: "",
},
}
records := GetPaginatedRecords(projectID, limit, offset)
if len(records) > 0 {
table.Records = records
table.Pagination.TotalRecords = records[0].TotalRecords
table.Pagination.PageStartRecord = offset + 1
table.Pagination.PageEndRecord = offset + len(records)
table.ShouldShowPagination = records[0].TotalRecords > limit
}
// This would keep the projectID as constant and only change the table state
if table.ShouldShowPagination && offset != 0 {
table.Pagination.PrevLink = fmt.Sprintf("/dashboard?project_id=%s&table_offset=%d", projectID, offset-limit)
}
if table.ShouldShowPagination && offset+limit < table.Pagination.TotalRecords {
table.Pagination.NextLink = fmt.Sprintf("/dashboard?project_id=%s&table_offset=%d", projectID, offset+limit)
}
return table
}
// ...
There’s probably a more elegant way to do this, but this approach is sufficient for my current use case.
File organization
I wanted to colocate my templates with their logic instead of dumping them all into a single /templates
folder:
.
|-- assets
| `-- index.css
|-- commons
| |-- components
| | |-- button.go
| | |-- button.html
| | |-- input.go
| | |-- input.html
| | |-- navbar.go
| | `-- navbar.html
| `-- templates
| `-- templates.go
|-- features
| |-- home
| | |-- home.go
| | `-- home.html
| |-- login
| | |-- login.go
| | `-- login.html
| `-- users
| |-- user_detail.html
| `-- users.go
|-- go.mod
|-- go.sum
`-- main.go
I also wanted to embed all these templates and css into a single Go binary.
Here’s how I implemented this:
// main.go
package main
import (
"embed"
"myapp/commons/templates"
)
//go:embed all:commons all:features
var resources embed.FS
//go:embed assets/*.css
var assets embed.FS
func main() {
// ..
templates.NewTemplates(resources)
// ..
}
// commons/templates/templates.go
package templates
import (
"bytes"
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"strings"
)
var tmpl *template.Template = nil
func NewTemplates(resources embed.FS) {
var paths []string
fs.WalkDir(resources, ".", func(path string, d fs.DirEntry, err error) error {
if strings.Contains(d.Name(), ".html") {
paths = append(paths, path)
}
return nil
})
tmpl = template.Must(template.ParseFS(resources, paths...))
}
func Render(w http.ResponseWriter, name string, data interface{}) {
var buffer bytes.Buffer
err := tmpl.ExecuteTemplate(&buffer, name, data)
if err != nil {
err = fmt.Errorf("error executing template: %w", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=UTF-8")
buffer.WriteTo(w)
}
The NewTemplates
function walks through all the template (*.html
) files inside the project and parses them. We also see the Render
function from earlier which renders the template and handles the errors.
Conclusion
That’s it! I feel this setup should be sufficient for small to medium-sized frontend codebases.
You can see a full implementation here or look at the demo app built using this approach.