Building a reactive web app in Go with Fir
Tags: go, webdev
Published: 2023-09-27 16:33:56

**Written by [Rahul Padalkar](https://blog.logrocket.com/author/rahulpadalkar/)✏️**

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?](#how-does-fir-work)
*   [Installing Fir in a Go project](#installing-fir-go-project)
*   [Building a counter app with Go and Fir](#building-counter-app-go-fir)
*   [Building a to-do app with Go, Fir, and Alpine.js](#building-to-do-app-go-fir-alpine-js)

You can check out the complete code for our simple demo apps in [this GitHub repository](https://github.com/rahulnpadalkar/fir-example). 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](https://blog.logrocket.com/wp-content/uploads/2023/09/img1-How-Fir-works.png) 

Fir offers [an Alpine.js plugin](https://blog.logrocket.com/getting-started-alpine/) 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](https://blog.logrocket.com/wp-content/uploads/2023/09/img2-Patch-DOM-Alpine-js-Fir-plugin.png) When the user triggers an event — for example, by clicking on a button — the event is sent to the server [via a WebSocket connection](https://blog.logrocket.com/using-websockets-go/). 

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 <a name="installing-fir-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:

```bash
mkdir go-fir-example
cd go-fir-example
```

Then run the following command:

```go
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
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 <a name="building-counter-app-go-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:

```go
// ./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:

```html
<!-- ./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: ![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](https://blog.logrocket.com/wp-content/uploads/2023/09/img3-Fir-rendering-process.png) 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 <a name="building-to-do-app-go-fir-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:

```go
// ./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:

```go
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:

```go
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 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:

```html
<!-- ./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](https://blog.logrocket.com/bulma-vs-tailwind-css-better-bootstrap-alternative/)
*   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:

```html
<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](https://blog.logrocket.com/making-http-requests-in-go/), Fir uses a WebSocket message that looks something like this: ![Developer Console Displaying Websocket Message Fir Uses To Send Event And Payload](https://blog.logrocket.com/wp-content/uploads/2023/09/img4-Developer-console-WebSocket-message.png) The server responds with a hydrated HTML template: ![Developer Console Showing Server Response With Hydrated Html Template](https://blog.logrocket.com/wp-content/uploads/2023/09/img5-Server-response-Fir-hydrated-HTML-template.png) 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:

```html
<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:

```html
<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:

```html
<!-- 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:

```html
<!-- 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”:

```go
@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](https://github.com/rahulnpadalkar/fir-example).

## 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](https://github.com/livefir/fir). 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:
```bash
$ npm i --save logrocket 

// Code:

import LogRocket from 'logrocket'; 
LogRocket.init('app/id');
```
Script Tag:
```javascript
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

[Get started now](https://lp.logrocket.com/blg/signup)