via

Getting started

From an empty directory to a counter every open tab follows: one file, the standard library plus via, no build step.

On this page

#Run a counter

  1. Check your Go version

    go version

    via needs Go 1.27 or newer.

  2. Create a module

    go mod init example.com/counter
    go get github.com/go-via/via
  3. Write main.go

    main.go
    package main
    
    import (
    	"log"
    	"net/http"
    	"sync/atomic"
    
    	"github.com/go-via/via"
    	"github.com/go-via/via/h"
    	"github.com/go-via/via/on"
    )
    
    type Counter struct{ n *atomic.Int64 }
    
    func (c *Counter) Inc(ctx *via.Ctx) { c.n.Add(1) }
    func (c *Counter) Dec(ctx *via.Ctx) { c.n.Add(-1) }
    
    func (c *Counter) View() h.H {
    	return h.Div(
    		h.Button(on.Click(c.Dec), h.Str("-")),
    		h.H1(h.Str(c.n.Load())),
    		h.Button(on.Click(c.Inc), h.Str("+")),
    	)
    }
    
    func main() {
    	r := via.Handler(Counter{n: new(atomic.Int64)})
    	err := http.ListenAndServe(":8080", r)
    	r.Close()
    	log.Fatal(err)
    }
    
  4. Run it

    go run .

    Open http://localhost:8080 and click +. The button POSTs to Inc, via renders Counter again, and the page morphs the new number in place.

  5. Open a second tab

    Click + in one tab. The other keeps the old number until its own next click or a reload: the count is shared, but nothing on this page pushes, so every click is one request and one response.

#The parts

  • A composition is a struct. Counter holds a pointer to the count, so every request's copy shares it.
  • Its View is a pure, ctx-free method that returns the markup.
  • An action is a method taking via.Ctx. on.Click takes the method value c.Inc, so a misspelled action does not compile.
  • via.Handler and via.Mount take the composition by value: no & at the call site, and a missing or mistyped View is a compile error.
  • r.Close() drains the live half once the server stops. http.Server.Shutdown does not.

#Make it live

main.go
package main

import (
	"log"
	"net/http"
	"sync"

	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
	"github.com/go-via/via/topic"
)

var (
	mu    sync.Mutex
	count int64
	moved = topic.New[int64]()
)

func add(n int64) {
	mu.Lock()
	defer mu.Unlock()
	count += n
	moved.Publish(count)
}

func load() int64 {
	mu.Lock()
	defer mu.Unlock()
	return count
}

type Counter struct{ N via.State[int64] }

func (c *Counter) Inc(ctx *via.Ctx) { add(1) }
func (c *Counter) Dec(ctx *via.Ctx) { add(-1) }

func (c *Counter) View() h.H {
	return h.Div(
		h.Button(on.Click(c.Dec), h.Str("-")),
		h.H1(c.N.Display()),
		h.Button(on.Click(c.Inc), h.Str("+")),
	)
}

func main() {
	r := via.Handler(Counter{N: via.StateTrack(moved, load)})
	log.Fatal(http.ListenAndServe(":8080", r))
}

The count moves to a package store and a topic.Topic announces each change. Rendering a via.State makes the unit live: each tab opens one SSE stream and every publish reaches it.

add holds the mutex across the change and the topic.Topic.Publish, so values go out in the order the count moved. via.StateTrack keeps the State equal to the store, and via.State.Display renders it.

load is StateTrack's reader: it runs at each init of the unit, so a new tab renders the current count. Dec is Inc with -1. via.Handler mounts Counter at / and returns the Router, which http.ListenAndServe serves on :8080.

#Two tabs, one count

The program above, running. Open this page in a second tab and click there: this card's Wire pane shows the patch arriving on this tab's stream with no POST from it. Clicks are capped at 30 a minute per client.

0

Try this

  • Open this page in a second tab, click + there, and come back.
  • Click + here: the POST answers 204 and the new number arrives on the stream.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"sync"

	"github.com/go-via/via"
	"github.com/go-via/via/h"
	"github.com/go-via/via/on"
	"github.com/go-via/via/topic"
)

// startMu covers the change and its publish together, so publishes leave in
// the order the count moved and the last one a tab sees is the current count.
var (
	startMu    sync.Mutex
	startCount int64
	startMoved = topic.New[int64]()
)

func startAdd(n int64) {
	startMu.Lock()
	defer startMu.Unlock()
	startCount += n
	startMoved.Publish(startCount)
}

func startLoad() int64 {
	startMu.Lock()
	defer startMu.Unlock()
	return startCount
}

func resetStart() {
	startMu.Lock()
	defer startMu.Unlock()
	startCount = 0
	startMoved.Publish(0)
}

// StartCounter is the live counter from Getting started: every tab on the page
// follows startCount through the topic, so a click in one redraws all of them.
type StartCounter struct {
	// Limiter: see shared_contract.go.
	Lim Limiter
	N   via.State[int64]
}

func NewStartCounter(lim Limiter) StartCounter {
	if lim == nil {
		panic("demos: NewStartCounter: StartCounter.Lim must not be nil")
	}
	return StartCounter{Lim: lim, N: via.StateTrack(startMoved, startLoad)}
}

func (c *StartCounter) Inc(ctx *via.Ctx) {
	if c.Lim.Allow(ctx) {
		startAdd(1)
	}
}

func (c *StartCounter) Dec(ctx *via.Ctx) {
	if c.Lim.Allow(ctx) {
		startAdd(-1)
	}
}

func (c *StartCounter) View() h.H {
	return h.Div(h.Class("row"),
		h.Button(on.Click(c.Dec), h.Str("−")),
		h.Output(c.N.Display()),
		h.Button(on.Click(c.Inc), h.Str("+")),
	)
}

#What happened

RequestWhat via does
GET /Renders Counter to HTML. The View displayed a State, so the page also opens an SSE stream for this tab.
POSTA click runs Inc on this tab's Counter, which publishes the new count.
204The POST's answer carries no body: Counter is a live unit, so the change travels on the stream instead.
SSE patchEvery tab's State receives the value through via.Ctx.Listen, re-renders its Counter, and via pushes the changed element down that tab's stream.

#Try this

  • Add a Reset method that stores 0 and publishes it, and a third button with on.Click(c.Reset). Every tab drops to 0.
  • Rename c.Inc to c.Increment in View only. go build stops with c.Increment undefined (type *Counter has no field or method Increment): an action is a method value, not a string.