Written by Rahul Padalkar✏️
Knowing JavaScript is usually a bare minimum requirement for web developers today. However, one Go package — the Fir toolkit — is making it possible to develop simple, reactive web applications without needing much JavaScript knowledge or experience with complex frameworks.
While Fir uses JavaScript under the hood, it’s hidden from developers. Instead, developers can use Fir’s own unique syntax to add reactivity to simple Go apps. Building more complex apps that need features like authentication flows, animations, or client-side validation might be challenging with Fir.
In this tutorial-style article, we’ll build a simple counter app that will help us understand the basics of Fir. Then, we’ll take it a step further by building a to-do app with Fir and its Alpine.js plugin to further explore its features. We will cover:
- How does Fir work?
- Installing Fir in a Go project
- Building a counter app with Go and Fir
- Building a to-do app with Go, Fir, and Alpine.js
You can check out the complete code for our simple demo apps in this GitHub repository. Let’s jump in!
How does Fir work?
Before we start building, let’s first take a look at Fir and how it works.
Fir uses templates to render HTML on the server side and then send it back to the browser. At a high level, it works as illustrated below:
Fir offers an Alpine.js plugin that allows developers to write enhanced reactive web applications. With this plugin, it becomes possible to patch the DOM rather than rendering the template on the server and sending it down to the client: When the user triggers an event — for example, by clicking on a button — the event is sent to the server via a WebSocket connection.
The server then executes the written Go code corresponding to that event and returns a rendered HTML template back to the client. Using Fir’s Alpine.js plugin, the DOM is patched at the appropriate place with the HTML returned by the server.
Just a note before we move ahead: Fir is still experimental and not production-ready. You should expect it to have breaking changes in upcoming releases, so keep this in mind before you use Fir in your project.
Installing Fir in a Go project
To follow this tutorial, you’ll need some familiarity with basic Go and HTML. Let’s get started with Fir in a new Go project by creating a folder as shown below:
mkdir go-fir-example
cd go-fir-example
Then run the following command:
go mod init go-fir/example
This command will create a Go module with the given name.
Next, let's install Fir so that we can use it in our code:
go get -u github.com/livefir/fir
With that done, we can start to play around with Fir.
Building a counter app with Go and Fir
Now that we have everything set up, let’s take the Fir library out for a spin. We’ll begin with a basic counter application to understand how the library works.
Start by creating a main
package in the main.go
file:
// ./main.go
package main
import (
"net/http"
"sync/atomic"
"github.com/livefir/fir"
)
func index() fir.RouteOptions {
var count int32
return fir.RouteOptions{
fir.ID("counter"),
fir.Content("counter.html"),
fir.OnLoad(func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.LoadInt32(&count))
}),
fir.OnEvent("inc", func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.AddInt32(&count, 1))
}),
fir.OnEvent("dec", func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.AddInt32(&count, -1))
}),
}
}
func main() {
controller := fir.NewController("counter_app", fir.DevelopmentMode(true))
http.Handle("/", controller.RouteFunc(index))
http.ListenAndServe(":9867", nil)
}
In the code above, we initialize a Fir controller by giving it a name and some options. Here, we pass the development mode as true
to enable console logging. Then, using the http
package, we set up a basic HTTP server and listen for connections on port 9867
.
The more important part to focus on is the index
function passed to the Fir controller. The index
function returns a fir.RouteOptions
slice with several RouteOptions
. We pass in various options in the slice:
- An
ID
-
Content
to be rendered when that route is hit - An
OnLoad
event handler - A couple of
OnEvent
handlers
In the OnLoad
handler, we load default values to a shared count
variable. Since the variable is shared, we use the sync/atomic
package to load and add values to the variable without any race conditions or overwriting.
We then use the RouteContext
to hydrate the HTML template. Notice that we pass count
as the first argument to the ctx.KV
function. We will see its significance in a moment.
Now let’s build a view for our app. To do that, create a counter.html
file and add the following to it:
<!-- ./counter.html -->
<!DOCTYPE html>
<html lang="en">
<body>
{{ block "count" . }}
<div>Count: {{ .count }}</div>
{{ end }}
<form method="post">
<button formaction="/?event=inc" type="submit">+</button>
<button formaction="/?event=dec" type="submit">-</button>
</form>
</body>
</html>
Two key things to note here are the usage of a template and the count
variable that we set earlier in OnEvent
handler in the HTML template.
We use the count
variable by surrounding it with curly braces {{ }}
. This syntax might remind you of the mustache syntax used in Vue.js. It helps Fir identify the dynamic parts of the template and render them using the variable value.
Another notable point is that clicking the buttons triggers a form action, sending an event
query parameter to the server. Depending on the value we send, Fir calls the appropriate onEvent
handler. The handler then changes the value of our shared variable, and Fir renders and sends the updated template.
The count
variable here is shared among multiple route calls. You can think of it like a global counter. If you have multiple windows open on the count page, updating one and refreshing the other will show the same count on both pages.
Here’s an overview of how the rendering process works: The counter app works by triggering a form submission after the user clicks a button, which sends a request to the server. The server renders the template and sends it back to the browser, meaning the entire webpage is re-rendered every time the count is changed.
This approach is fine for a simple application. However, for a complicated application, it can cause serious performance issues in terms of UX and page re-renders. For example, if the page has many animations and elements, you will end up with an unresponsive and janky UI that also hampers UX and load times.
To fix this, we can use Fir’s Alpine.js plugin to avoid complete re-renders. We can load the plugin via the CDN. Let’s see how this works by building a simple to-do app with Go and Fir enhanced with Alpine.
Building a to-do app with Go, Fir, and Alpine.js
To get started, let’s first make the necessary routing changes. We’ll move the counter to the /counter
route and add the to-do application to the root /
route:
// ./main.go
package main
import (
"fmt"
"net/http"
"sync/atomic"
"github.com/livefir/fir"
)
func index() fir.RouteOptions {
var count int32
return fir.RouteOptions{
fir.ID("counter"),
fir.Content("counter.html"),
fir.OnLoad(func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.LoadInt32(&count))
}),
fir.OnEvent("inc", func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.AddInt32(&count, 1))
}),
fir.OnEvent("dec", func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.AddInt32(&count, -1))
}),
}
}
func main() {
if err != nil {
panic(err)
}
controller := fir.NewController("fir_app", fir.DevelopmentMode(true))
http.Handle("/counter", controller.RouteFunc(index))
http.Handle("/", controller.RouteFunc(todo(db)))
http.ListenAndServe(":9867", nil)
}
Now let’s install two new packages:
- BoltHold, a Go package that makes dealing with BoltDB a bit easier
-
uuid
, a Go package for generating unique UUIDs
BoltDB is a very simple key-value database written in Go. We will use BoltHold on top of it to make querying and manipulating data easy:
package main
import (
"fmt"
"net/http"
"sync/atomic"
uuid "github.com/twinj/uuid"
"github.com/livefir/fir"
"github.com/timshannon/bolthold"
)
func index() fir.RouteOptions {
var count int32
return fir.RouteOptions{
fir.ID("counter"),
fir.Content("counter.html"),
fir.OnLoad(func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.LoadInt32(&count))
}),
fir.OnEvent("inc", func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.AddInt32(&count, 1))
}),
fir.OnEvent("dec", func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.AddInt32(&count, -1))
}),
}
}
func main() {
db, err := bolthold.Open("todos1.db", 0666, nil)
if err != nil {
panic(err)
}
controller := fir.NewController("fir_app", fir.DevelopmentMode(true))
http.Handle("/counter", controller.RouteFunc(index))
http.Handle("/", controller.RouteFunc(todo(db)))
http.ListenAndServe(":9867", nil)
}
Now let’s add the route handler:
package main
import (
"fmt"
"net/http"
"sync/atomic"
uuid "github.com/twinj/uuid"
"github.com/livefir/fir"
"github.com/timshannon/bolthold"
)
func index() fir.RouteOptions {
var count int32
return fir.RouteOptions{
fir.ID("counter"),
fir.Content("counter.html"),
fir.OnLoad(func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.LoadInt32(&count))
}),
fir.OnEvent("inc", func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.AddInt32(&count, 1))
}),
fir.OnEvent("dec", func(ctx fir.RouteContext) error {
return ctx.KV("count", atomic.AddInt32(&count, -1))
}),
}
}
type TodoItem struct {
Id string `boltholdKey:"Id"`
Text string `json:"todo"`
Status string
}
type deleteParams struct {
TodoID []string `json:"todoID"`
}
func todo(db *bolthold.Store) fir.RouteFunc {
return func() fir.RouteOptions {
return fir.RouteOptions{
fir.ID("todo"),
fir.Content("todo.html"),
fir.OnEvent("add-todo", func(ctx fir.RouteContext) error {
todoItem := new(TodoItem)
if err := ctx.Bind(todoItem); err != nil {
return err
}
todoItem.Status = "not-complete"
todoItem.Id = uuid.NewV4().String()
if err := db.Insert(todoItem.Id, todoItem); err != nil {
return err
}
return ctx.Data(todoItem)
}),
fir.OnEvent("delete-todo", func(ctx fir.RouteContext) error {
req := new(deleteParams)
if err := ctx.Bind(req); err != nil {
return err
}
if err := db.Delete(req.TodoID[0], &TodoItem{}); err != nil {
fmt.Println(err)
return err
}
return nil
}),
fir.OnEvent("mark-complete", func(ctx fir.RouteContext) error {
req := new(deleteParams)
if err := ctx.Bind(req); err != nil {
return err
}
var todoItem TodoItem
if err := db.Get(req.TodoID[0], &todoItem); err != nil {
return err
}
todoItem.Status = "completed"
if err := db.Update(req.TodoID[0], &todoItem); err != nil {
return err
}
return ctx.Data(todoItem)
}),
fir.OnLoad(func(ctx fir.RouteContext) error {
var todos []TodoItem
if err := db.Find(&todos, &bolthold.Query{}); err != nil {
return err
}
return ctx.Data(map[string]any{"todos": todos})
}),
}
}
}
func main() {
db, err := bolthold.Open("todos.db", 0666, nil)
if err != nil {
panic(err)
}
controller := fir.NewController("fir_app", fir.DevelopmentMode(true))
http.Handle("/counter", controller.RouteFunc(index))
http.Handle("/", controller.RouteFunc(todo(db)))
http.ListenAndServe(":9867", nil)
}
In the code above, we pass the fir.RouteOptions
returned by the todo
function to the Fir controller. This is on line 94 of our file.
The RouteOptions
are basically the same as in the counter app, but with some extra events and a different content file: todo.html
. This route handles three events:
- The event handler for the
add-todo
event creates a to-do item of typeTodoItem
with data sent in thepost
payload. It then inserts it into BoltDB and returns the item back to the client - The event handler for
delete-todo
deletes the to-do item whose ID is sent in the post request payload - The event handler for
mark-complete
marks the to-do item as complete, updates the database with this new value, and sends the updated item to the client
We use two important methods provided via the fir.RouteContext
to read and write values:
- The
Bind
method extracts values from the POST payload. To do this, we define a type that mirrors the payload structure and pass it to theBind
method. Using tags, we tell theBind
method how to map values to the appropriate object properties - The
Data
method sets values, populates the corresponding template, and sends the rendered HTML back to the client
Now that we have covered the server portion of our app, let’s take a look at the HTML template and the bindings:
<!-- ./todo.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script
defer
src="https://unpkg.com/@livefir/fir@latest/dist/fir.min.js"
></script>
<script
defer
src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
></script>
<title>TODO - App</title>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"
/>
</head>
<body>
<div x-data>
<div>TODO List</div>
<div class="columns">
<form
method="post"
@submit.prevent="$fir.submit()"
x-ref="addTodo"
action="/?event=add-todo"
@fir:add-todo:ok::todo="$refs.addTodo.reset()"
>
<div class="column">
<input
placeholder="Todo item"
class="input is-info"
name="todo"
type="text"
/>
</div>
<div class="column">
<button class="button" type="submit">Add Item</button>
</div>
</form>
</div>
<div>
<div class="columns center">
<div class="column"><b>Title</b></div>
<div class="column"><b>Status</b></div>
<div class="column"><b>Actions</b></div>
</div>
<div @fir:add-todo:ok::todo="$fir.appendEl()">
{{ range .todos }} {{ block "todo" . }}
<div
fir-key="{{ .Id }}"
class="columns {{ .Status }}"
@fir:delete-todo:ok="$fir.removeEl()"
>
<div class="column">{{ .Text }}</div>
<div
class="column"
@fir:mark-complete:ok::mark-complete="$fir.replace()"
>
{{ block "mark-complete" . }}
<div>{{ .Status }}</div>
{{ end }}
</div>
<form
method="post"
@submit.prevent="$fir.submit()"
class="columns column"
>
<input type="hidden" name="todoID" value="{{ .Id }}" />
<button
class="column button is-danger"
formaction="/?event=delete-todo"
>
Delete
</button>
<button
class="column button is-primary"
formaction="/?event=mark-complete"
>
Complete
</button>
</form>
</div>
{{ end }} {{end}}
</div>
</div>
</div>
</body>
</html>
There is a lot to unpack here, so let’s break it down:
- We load the Alpine.js plugin for Fir by adding two
script
tags. The first one loads the plugin, and the second one loads Alpine.js - A third script loads Bulma for basic styling
- There are two forms here:
- One that adds a new to-do item
- One that deletes the to-do item or marks it as complete
Let’s take a closer look at the first form:
<form
method="post"
@submit.prevent="$fir.submit()"
x-ref="addTodo"
action="/?event=add-todo"
@fir:add-todo:ok::todo="$refs.addTodo.reset()"
>
<div class="column">
<input
placeholder="Todo item"
class="input is-info"
name="todo"
type="text"
/>
</div>
<div class="column">
<button class="button" type="submit">Add Item</button>
</div>
</form>
Here, when the user clicks on the button, we prevent the default browser action and instead let the Fir plugin take care of the form submission. You can see where we set this up on line three.
Fir sends the event add-todo
to the server along with the payload. Instead of sending it via an HTML post request, Fir uses a WebSocket message that looks something like this: The server responds with a hydrated HTML template: Note that to look at the messages sent and received by the client, you can open the network tab in your browser’s dev tools and look for a request with the request code 101
. Click on it and open the Messages
tab.
After receiving this response, Fir identifies the target and follows the action tagged against that target. In this case, it appends the HTML to the element to which this target is attached, as shown on line one below:
<div @fir:add-todo:ok::todo="$fir.appendEl()">
{{ range .todos }} {{ block "todo" . }}
<div
fir-key="{{ .Id }}"
class="columns {{ .Status }}"
@fir:delete-todo:ok="$fir.removeEl()"
>
.
.
.
.
</div>
{{ end }} {{ end }}
</div>
Next, let’s look at the second form, which deletes or marks the to-do item as complete:
<form
method="post"
@submit.prevent="$fir.submit()"
class="columns column"
>
<input type="hidden" name="todoID" value="{{ .Id }}" />
<button
class="column button is-danger"
formaction="/?event=delete-todo"
>
Delete
</button>
<button
class="column button is-primary"
formaction="/?event=mark-complete"
>
Complete
</button>
</form>
As before, when the user clicks the button, we prevent the default browser action and delegate it to the Fir plugin for handling.
To let the server know which to-do item to perform operations on, we create a hidden input field with the same value as the Id
field. When the user submits the form, this value is sent to the server.
For actions such as deleting an item or marking it as complete, Fir manipulates HTML content as declared in the HTML template below. For instance, when marking an item as complete, Fir replaces this element's content:
<!-- when marking as complete -->
<div
class="column"
@fir:mark-complete:ok::mark-complete="$fir.replace()"
>
{{ block "mark-complete" . }}
<div>{{ .Status }}</div>
{{ end }}
</div>
Conversely, when deleting a to-do item, Fir removes this element:
<!-- when deleting a todo item -->
<div
fir-key="{{ .Id }}"
class="columns {{ .Status }}"
@fir:delete-todo:ok="$fir.removeEl()"
>
.
.
.
.
</div>
The key takeaway here is how Fir uses event binding to manipulate the browser DOM elements. For example, @fir:delete-todo:ok="$fir.removeEl()"
can be read as, “When the delete event is successful, remove this element.”
Similarly, the code below can be read as, “When the mark-complete event is successful, replace the content in this element”:
@fir:mark-complete:ok::mark-complete="$fir.replace()"
The final point to note regards the templating syntax. To render a list of things, we use the range
keyword. The block
keyword marks the start of the block, while the end
keyword signifies its end. To print values in a variable, we prefix the name of the variable with a .
dot.
That’s it! You can find the full code for our demo Fir and Go projects on GitHub.
Conclusion
Fir allows developers to write interactive web applications by using Go and HTML sprinkled with its own template syntax. This is a really cool tool for Go developers who want to develop simple web applications without dealing directly with JavaScript.
While it’s possible to develop simple applications or landing pages with Fir, it might be challenging to build more complicated projects due to its limited resources and incomplete documentation. If you do run into problems while using Fir, trial and error may be the best way to find a solution.
Fir is an open source project, so if you are interested in contributing you can head over to their GitHub repository. You can also open an issue there if you encounter any problems you can’t solve on your own.
Get set up with LogRocket's modern React error tracking in minutes:
- Visit https://logrocket.com/signup/ to get an app ID.
- Install LogRocket via NPM or script tag.
LogRocket.init()
must be called client-side, not server-side.
NPM:
$ npm i --save logrocket
// Code:
import LogRocket from 'logrocket';
LogRocket.init('app/id');
Script Tag:
Add to your HTML:
<script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script>
<script>window.LogRocket && window.LogRocket.init('app/id');</script>
3.(Optional) Install plugins for deeper integrations with your stack:
- Redux middleware
- ngrx middleware
- Vuex plugin
Top comments (2)
So, it's kind of like Laravel Livewire for
go
.Thank you to bring this up ! It reminds me of Wicket, one of the first server side java web application framework :-)