Build progressively enhanced reactive html apps

using Go, html/template & Alpine.js

The Fir toolkit is designed for Go developers with moderate html/css & js skills who want to progressively build reactive web apps without mastering complex web frameworks. It includes a Go library and an Alpine.js plugin.

Scroll below to see a demo & a quickstart guide or read about how it works.

1. Start with html/template

A Fir web page begins as a standard server-side rendered page which reloads to show the new state on user interaction. Click the buttons below to see it in action.

Count: 117

When the html form is submitted, its is handled on the server in onEvent functions registered for inc and dec. The action/formaction attribute must be of the format /?event=inc where inc is the event name. Notice there is no javascript in the page.
Go the the directory where you have these files and run:

1go run counter.go

Open your browser and go to http://localhost:9867 to see the counter in action.

 1<!DOCTYPE html>
 2<html lang="en">
 3  <body>
 4    <div>Count: {{ .count }}</div>
 5
 6    <form method="post">
 7      <button formaction="/?event=inc" type="submit">+</button>
 8      <button formaction="/?event=dec" type="submit">-</button>
 9    </form>
10  </body>
11</html>
 1package main
 2
 3import (
 4	"net/http"
 5	"sync/atomic"
 6
 7	"github.com/livefir/fir"
 8)
 9
10func index() fir.RouteOptions {
11	var count int32
12	return fir.RouteOptions{
13		fir.ID("counter"),
14		fir.Content("counter.html"),
15		fir.OnLoad(func(ctx fir.RouteContext) error {
16			return ctx.KV("count", atomic.LoadInt32(&count))
17		}),
18		fir.OnEvent("inc", func(ctx fir.RouteContext) error {
19			return ctx.KV("count", atomic.AddInt32(&count, 1))
20		}),
21		fir.OnEvent("dec", func(ctx fir.RouteContext) error {
22			return ctx.KV("count", atomic.AddInt32(&count, -1))
23		}),
24	}
25}
26
27func main() {
28	controller := fir.NewController("counter_app", fir.DevelopmentMode(true))
29	http.Handle("/", controller.RouteFunc(index))
30	http.ListenAndServe(":9867", nil)
31}

2. Enhance with alpinejs

Later we use fir’s alpinejs plugin to enhance the form submission and receive the re-rendered template as an event. The event is handled by the $fir.replace() helper function which updates the inner content of the div on which the event listener is declared. Click the buttons below to see reactivity in action. Open this page in two tabs to see the changes in one tab reflected in the other.

Count: 117

As you can notice, the count value is updated without a page reload. The server side code remains unchanged.
Fir’s magic expression @fir:event-name:event-state::template-name piggybacks on alpinejs event binding syntax to declare html/templates to be re-rendered on the server.

1<div 
2    @fir:inc:ok::count="$fir.replace()"
3    @fir:dec:ok::count="$fir.replace()">
4    {{ block "count" . }}
5        <div>Count: {{ .count }}</div>
6    {{ end }}
7</div>

If the handler response status for event inc is ok then re-render the template named count on the server and return the html output to the event listener as a CustomEvent. The CustomEvent.detail property is used by the alpinejs plugin helper $fir.replace() to update the div on which the listener is declared.

Alternatively there is a short-hand form for wiring up multiple events with the same action.

1<div @fir:[inc:ok,dec:ok]::count="$fir.replace()">
2    {{ block "count" . }}
3    <div>Count: {{ .count }}</div>
4    {{ end }}
5</div>

Moreover, fir is able to automatically extract a template from elements on which fir events are declared. The above snippet can be further simplified.

1<div @fir:[inc:ok,dec:ok]="$fir.replace()">
2   Count: {{ .count }}
3</div>

Go the the directory where you have these files and run:

1go run counter.go

Open your browser and go to http://localhost:9867 to see the reactive counter in action.

 1<!DOCTYPE html>
 2<html lang="en">
 3  <head>
 4    <script
 5      defer
 6      src="https://unpkg.com/@livefir/fir@latest/dist/fir.min.js"
 7    ></script>
 8
 9    <script
10      defer
11      src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"
12    ></script>
13  </head>
14
15  <body>
16    <div x-data>
17      <div @fir:[inc:ok,dec:ok]="$fir.replace()">Count: {{ .count }}</div>
18      <form method="post" @submit.prevent="$fir.submit()">
19        <button formaction="/?event=inc" type="submit">+</button>
20        <button formaction="/?event=dec" type="submit">-</button>
21      </form>
22    </div>
23  </body>
24</html>
 1package main
 2
 3import (
 4	"net/http"
 5	"sync/atomic"
 6
 7	"github.com/livefir/fir"
 8)
 9
10func index() fir.RouteOptions {
11	var count int32
12	return fir.RouteOptions{
13		fir.ID("counter"),
14		fir.Content("counter.html"),
15		fir.OnLoad(func(ctx fir.RouteContext) error {
16			return ctx.KV("count", atomic.LoadInt32(&count))
17		}),
18		fir.OnEvent("inc", func(ctx fir.RouteContext) error {
19			return ctx.KV("count", atomic.AddInt32(&count, 1))
20		}),
21		fir.OnEvent("dec", func(ctx fir.RouteContext) error {
22			return ctx.KV("count", atomic.AddInt32(&count, -1))
23		}),
24	}
25}
26
27func main() {
28	controller := fir.NewController("counter_app", fir.DevelopmentMode(true))
29	http.Handle("/", controller.RouteFunc(index))
30	http.ListenAndServe(":9867", nil)
31}