Building a reactive web app in Go with Fir

mangelosanto

Matt Angelosanto

Posted on September 27, 2023

Building a reactive web app in Go with Fir

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:

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: Diagram Showing How Fir Generates Html From Template, Sends Html To Client, Then Receives Event Or Info Back From Browser

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: Diagram Showing How Using Alpine Js Plugin With Fir Changes Process To Patch Dom Rather Than Rendering Template On Server And Sending To 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
Enter fullscreen mode Exit fullscreen mode

Then run the following command:

go mod init go-fir/example
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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: Diagram Showing Fir Rendering Process Where User's Button Click Triggers Form Submission And Sends Request To Server, After Which Server Renders Template And Sends It Back To The Browser 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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 type TodoItem with data sent in the post 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 the Bind method. Using tags, we tell the Bind 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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: Developer Console Displaying Websocket Message Fir Uses To Send Event And Payload The server responds with a hydrated HTML template: Developer Console Showing Server Response With 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()"
Enter fullscreen mode Exit fullscreen mode

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:

  1. Visit https://logrocket.com/signup/ to get an app ID.
  2. 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');
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

3.(Optional) Install plugins for deeper integrations with your stack:

  • Redux middleware
  • ngrx middleware
  • Vuex plugin

Get started now

💖 💪 🙅 🚩
mangelosanto
Matt Angelosanto

Posted on September 27, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related