via

Live state

The server pushes to a tab when a unit on the page asks for it.

On this page

#A page is plain until a unit goes live

Any one of these makes its unit live, and the page streams:

TriggerWhereEffect
ctx.Tick(d, fn)OnInitRuns fn every d, then re-renders the unit and pushes the patch.
ctx.Listen(t, fn)OnInitRuns fn for every value published on t, then re-renders and pushes. Unsubscribes when the tab goes away.
State.Display(), List.Each(row)ViewRenders server state; a change pushes a patch on the tab's stream.
via.StateTrack(t, load)field literalSeeds from load, seeds again once the stream has subscribed, then follows t. For a source fixed at mount.
State.Track(ctx, t, load)OnInitThe same, for a source that depends on the request, such as a path param.

Without a trigger, a page is a request and a response. With one, via opens one SSE connection for the tab and pushes element patches down it. One stream per tab, not per unit: every live unit on the page shares one connection. A tab is that one connection, so "per tab" in these docs means per connection: a reload is a new tab.

Actions do not change transport. A click still POSTs; an action on a live unit answers as a frame on the stream instead of in the POST's own body.

Liveness is decided once, by the render that served the page. An action cannot turn a plain page into a live one.

#Server state

#Reveal

via.State is server-held and per tab. via.State.Display renders it and marks its unit live; via.State.Get does not. Here the State is rendered on every request and only hidden, so this unit is live from the GET, and the reveal comes back as a frame on the stream.

Try this

  • Click reveal: the Wire pane shows the POST, then the patch arriving on the SSE stream.
  • Reload and reveal again: the time moves, because each connection runs OnInit afresh.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"time"

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

// LiveReveal hides a State without leaving it out of the render: Display runs
// on every render, hidden or not, so the unit is live from the GET and the
// reveal has a stream to arrive on.
type LiveReveal struct {
	Open  via.State[bool]
	Since via.State[string]
}

func (r *LiveReveal) OnInit(ctx *via.Ctx) error {
	r.Since.Set("held since " + time.Now().Format("15:04:05"))
	return nil
}

func (r *LiveReveal) Toggle(ctx *via.Ctx) { r.Open.Set(!r.Open.Get()) }

func (r *LiveReveal) View() h.H {
	label := "reveal"
	if r.Open.Get() {
		label = "hide"
	}
	return h.Div(
		h.Button(on.Click(r.Toggle), h.Str(label)),
		h.P(h.Class("metric"), h.Hidden(!r.Open.Get()), r.Since.Display()),
	)
}

#Timers

#Pulse

ctx.Tick(time.Second, p.beat) is the whole subscription. Every beat runs the handler on the unit's goroutine, re-renders this unit and patches it; the header, the sidebar and the other demos are not touched. Tick is valid only inside OnInit; a later call registers nothing and logs.

00:00

beat 0 since this tab connected

Try this

  • Open Wire: one patch a second, carrying only this card's markup.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"fmt"
	"time"

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

// Pulse is the smallest thing that streams: OnInit asks for a tick, so via
// opens this tab's SSE connection and pushes a re-render of this region — and
// only this region — every second.
type Pulse struct {
	Beats  via.State[int]
	Uptime via.State[string]
}

func (p *Pulse) OnInit(ctx *via.Ctx) error {
	p.Uptime.Set(pulseClock(0))
	ctx.Tick(time.Second, p.beat)
	return nil
}

func (p *Pulse) beat(ctx *via.Ctx) {
	n := p.Beats.Get() + 1
	p.Beats.Set(n)
	p.Uptime.Set(pulseClock(n))
}

func pulseClock(secs int) string { return fmt.Sprintf("%02d:%02d", secs/60, secs%60) }

func (p *Pulse) View() h.H {
	return h.Div(
		h.P(h.Class("metric"), p.Uptime.Display()),
		h.P(h.Class("note"), h.Str("beat "), p.Beats.Display(), h.Str(" since this tab connected")),
	)
}

#Topics

#Broadcast feed

A topic.Topic is built with topic.New and held for the life of the process; the zero Topic is not usable. via.Ctx.Listen subscribes this unit, runs the handler on the unit's own goroutine for every value, and unsubscribes when the tab goes away. The list is this tab's own and keeps the newest 50; posting is capped at 20 a minute per client IP.

    Try this

    Wire

    Use the demo to see its requests and patches here.

    Source
    package demos
    
    import (
    	"strconv"
    	"sync/atomic"
    	"time"
    
    	"github.com/go-via/via"
    	"github.com/go-via/via/h"
    	"github.com/go-via/via/on"
    	"github.com/go-via/via/topic"
    )
    
    // feedTopic is a plain package-level value. Nothing about it is via's: any
    // goroutine may Publish to it, and a unit's ctx.Listen is what turns a publish
    // into a frame on that unit's stream.
    var (
    	feedTopic = topic.New[FeedPost]()
    	feedSeq   atomic.Int64
    )
    
    // FeedPost is one broadcast line. Topics carry whatever type you give them.
    type FeedPost struct {
    	At   time.Time
    	Text string
    }
    
    // Feed shows every post made while this tab is open — it mirrors no store, so
    // a tab that arrives late starts empty. A value every tab must agree on wants
    // State.Track instead.
    type Feed struct {
    	// Limiter, keepRows and trim: see shared_contract.go.
    	Lim    Limiter
    	Posts  via.List[FeedPost]
    	Notice via.State[string]
    }
    
    // NewFeed takes the limiter the page owns; a nil one is a wiring mistake, so
    // it fails here rather than on the first render.
    func NewFeed(lim Limiter) Feed {
    	if lim == nil {
    		panic("demos: NewFeed: Feed.Lim must not be nil")
    	}
    	return Feed{Lim: lim}
    }
    
    func (f *Feed) OnInit(ctx *via.Ctx) error {
    	ctx.Listen(feedTopic, f.recv)
    	return nil
    }
    
    // recv runs on this unit's own goroutine, serialized with its ticks, so it may
    // touch unit state without a lock.
    func (f *Feed) recv(ctx *via.Ctx, p FeedPost) {
    	f.Posts.Append(p)
    	trim(&f.Posts, keepRows)
    }
    
    func (f *Feed) Post(ctx *via.Ctx) {
    	if !f.Lim.Allow(ctx) {
    		f.Notice.Set("slow down — 20 posts a minute")
    		return
    	}
    	f.Notice.Set("")
    	feedTopic.Publish(FeedPost{At: time.Now(), Text: "event #" + strconv.FormatInt(feedSeq.Add(1), 10)})
    }
    
    func (f *Feed) View() h.H {
    	return h.Div(
    		h.Div(h.Class("row"),
    			h.Button(on.Click(f.Post), h.Str("post an event")),
    			h.Span(h.Class("note"), f.Notice.Display()),
    		),
    		// A scroll container is only keyboard-scrollable while it can hold
    		// focus, and nothing inside this one is focusable.
    		h.Ul(h.Class("loglist"), h.TabIndex(0), h.Role("log"), h.Aria("label", "Posted events"), f.Posts.Each(f.row)),
    	)
    }
    
    func (f *Feed) row(p FeedPost) h.H { return h.Li(h.Str(p.At.Format("15:04:05") + "  " + p.Text)) }
    

    #Shared state across tabs

    #Shared counter

    State is per tab, so a number every visitor must agree on lives in a store you own, here an int64 behind a mutex held across the change and the publish, so tabs never settle on an older value. The topic only announces that the store moved. via.StateTrack keeps this tab's State equal to it: seed at init, seed again once the stream has subscribed, then follow every publish.

    The literal form is for a source fixed at mount, as here, and needs no OnInit. via.State.Track in OnInit is for a source that depends on the request, such as the room named by a path param. The counter resets to zero every 15 minutes, through the topic, so open tabs redraw.

    0

    Try this

    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"
    )
    
    // sharedHits is the store and the topic announces that it moved. sharedMu
    // covers each 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 (
    	sharedMu    sync.Mutex
    	sharedHits  int64
    	sharedTopic = topic.New[int64]()
    )
    
    func sharedAdd(n int64) {
    	sharedMu.Lock()
    	defer sharedMu.Unlock()
    	sharedHits += n
    	sharedTopic.Publish(sharedHits)
    }
    
    func sharedLoad() int64 {
    	sharedMu.Lock()
    	defer sharedMu.Unlock()
    	return sharedHits
    }
    
    func resetShared() {
    	sharedMu.Lock()
    	defer sharedMu.Unlock()
    	sharedHits = 0
    	sharedTopic.Publish(0)
    }
    
    // Shared follows the counter through the via.StateTrack literal NewShared
    // builds; the topic re-seeds it on every publish.
    type Shared struct {
    	// Limiter: see shared_contract.go.
    	Lim    Limiter
    	Hits   via.State[int64]
    	Notice via.State[string]
    }
    
    // NewShared takes the limiter the page owns, and seeds Hits from the store the
    // topic announces. A nil limiter is a wiring mistake, so it fails here rather
    // than on the first render.
    func NewShared(lim Limiter) Shared {
    	if lim == nil {
    		panic("demos: NewShared: Shared.Lim must not be nil")
    	}
    	return Shared{Lim: lim, Hits: via.StateTrack(sharedTopic, sharedLoad)}
    }
    
    func (s *Shared) Inc(ctx *via.Ctx) {
    	if !s.Lim.Allow(ctx) {
    		s.Notice.Set("slow down — 20 clicks a minute")
    		return
    	}
    	s.Notice.Set("")
    	sharedAdd(1)
    }
    
    func (s *Shared) View() h.H {
    	return h.Div(
    		h.P(h.Class("metric"), s.Hits.Display()),
    		h.Div(h.Class("row"),
    			h.Button(on.Click(s.Inc), h.Str("+1 for everyone")),
    			h.Span(h.Class("note"), s.Notice.Display()),
    		),
    	)
    }
    

    Track works on a via.List too. The store publishes a copy of the whole slice on every change, and each tab's list follows it:

    wall.go
    type Board struct {
    	mu      sync.Mutex
    	notes   []string
    	changed *topic.Topic[[]string]
    }
    
    func NewBoard() *Board { return &Board{changed: topic.New[[]string]()} }
    
    func (b *Board) Add(note string) {
    	b.mu.Lock()
    	defer b.mu.Unlock()
    	b.notes = append(b.notes, note)
    	b.changed.Publish(slices.Clone(b.notes))
    }
    
    func (b *Board) Notes() []string {
    	b.mu.Lock()
    	defer b.mu.Unlock()
    	return slices.Clone(b.notes)
    }
    
    type Wall struct {
    	Board *Board
    	Notes via.List[string]
    }
    
    func (w *Wall) OnInit(ctx *via.Ctx) error {
    	w.Notes.Track(ctx, w.Board.changed, w.Board.Notes)
    	return nil
    }
    
    func (w *Wall) View() h.H        { return h.Ul(w.Notes.Each(w.row)) }
    func (w *Wall) row(n string) h.H { return h.Li(h.Str(n)) }
    

    #Per-session delivery

    #Ping me

    One topic carries every visitor's pings. Each event names the session it is for, and the Listen handler drops the rest. ctx.Session().ID() is the routing key: it is stable, it is not the cookie, and it grants nothing. The session is the unit of fan-out, not the tab.

      Try this

      • Ping once here. The first ping mints the session. A tab opened before then joins once it pings or reloads; pings from other tabs cannot reach it until it sends the cookie.
      • Open in a second tab ↗
      • Ping from the second tab: both tabs of this browser light up.
      • Open this page in a different browser and ping: only the browser that pinged lights up.
      Wire

      Use the demo to see its requests and patches here.

      Source
      package demos
      
      import (
      	"time"
      
      	"github.com/go-via/via"
      	"github.com/go-via/via/h"
      	"github.com/go-via/via/on"
      	"github.com/go-via/via/topic"
      )
      
      // pingEvent is addressed: one topic carries every visitor's pings and each
      // unit drops the ones that are not its own. A topic per session would be a
      // map of topics nobody ever cleans up.
      type pingEvent struct {
      	To  string
      	Msg string
      }
      
      var pingTopic = topic.New[pingEvent]()
      
      // Ping is per-user fan-out over a shared topic, keyed by the session id.
      type Ping struct {
      	// Limiter, keepRows and trim: see shared_contract.go.
      	Lim    Limiter
      	sid    string
      	gone   chan struct{}
      	Msgs   via.List[string]
      	Notice via.State[string]
      }
      
      // NewPing takes the limiter the page owns; a nil one is a wiring mistake, so
      // it fails here rather than on the first render.
      func NewPing(lim Limiter) Ping {
      	if lim == nil {
      		panic("demos: NewPing: Ping.Lim must not be nil")
      	}
      	return Ping{Lim: lim}
      }
      
      func (p *Ping) OnInit(ctx *via.Ctx) error {
      	p.sid = ctx.Session().ID()
      	p.gone = make(chan struct{})
      	ctx.Listen(pingTopic, p.recv)
      	ctx.OnDispose(p.closeGone)
      	return nil
      }
      
      func (p *Ping) closeGone() { close(p.gone) }
      
      func (p *Ping) recv(ctx *via.Ctx, e pingEvent) {
      	if e.To != p.sid {
      		return
      	}
      	p.Msgs.Append(e.Msg)
      	trim(&p.Msgs, keepRows)
      }
      
      func (p *Ping) Send(ctx *via.Ctx) {
      	if !p.Lim.Allow(ctx) {
      		p.Notice.Set("slow down — 20 pings a minute")
      		return
      	}
      	// Minting the session here, on a rate-limited action, keeps a crawler's
      	// GETs out of the session store.
      	p.sid = ctx.Session().Ensure()
      	p.Notice.Set("scheduled")
      	// A goroutine may not touch unit state; publishing is how it reaches one.
      	// OnDispose closes gone when this tab's stream ends, so a ping from a tab
      	// closed within 3s is dropped.
      	to, gone := p.sid, p.gone
      	time.AfterFunc(3*time.Second, func() {
      		select {
      		case <-gone:
      			return
      		default:
      		}
      		pingTopic.Publish(pingEvent{To: to, Msg: "ping at " + time.Now().Format("15:04:05")})
      	})
      }
      
      func (p *Ping) View() h.H {
      	return h.Div(
      		h.Div(h.Class("row"),
      			h.Button(on.Click(p.Send), h.Str("ping me in 3s")),
      			h.Span(h.Class("note"), p.Notice.Display()),
      		),
      		h.Ul(h.Class("loglist"), h.TabIndex(0), h.Role("log"), h.Aria("label", "Pings received"), p.Msgs.Each(p.row)),
      	)
      }
      
      func (p *Ping) row(m string) h.H { return h.Li(h.Str(m)) }
      

      #Presence

      #Tabs connected

      via.Ctx.OnConnect runs once when a unit's stream opens and via.Ctx.OnDispose when it closes, so the pair brackets a real connection: a GET that never connects counts for nothing. They move a count and publish it under one lock; via.StateTrack does the rest. Neither hook makes a unit live on its own; the tracked State does.

      0

      tabs connected to this page, yours included

      Try this

      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/topic"
      )
      
      // presenceTabs is the head count and the topic tells every tab it moved.
      // presenceMu covers each change and its publish together, as in shared.go, so
      // the last count a tab sees is the current one.
      var (
      	presenceMu    sync.Mutex
      	presenceTabs  int64
      	presenceTopic = topic.New[int64]()
      )
      
      func presenceAdd(n int64) {
      	presenceMu.Lock()
      	defer presenceMu.Unlock()
      	presenceTabs += n
      	presenceTopic.Publish(presenceTabs)
      }
      
      func presenceLoad() int64 {
      	presenceMu.Lock()
      	defer presenceMu.Unlock()
      	return presenceTabs
      }
      
      // LivePresence counts the tabs holding a stream open on this page. OnConnect
      // and OnDispose run only on a real connection, so a crawler's GET, which
      // renders the page but never connects, counts for nothing.
      type LivePresence struct {
      	Tabs via.State[int64]
      }
      
      func NewLivePresence() LivePresence {
      	return LivePresence{Tabs: via.StateTrack(presenceTopic, presenceLoad)}
      }
      
      func (p *LivePresence) OnInit(ctx *via.Ctx) error {
      	ctx.OnConnect(p.join)
      	ctx.OnDispose(p.leave)
      	return nil
      }
      
      func (p *LivePresence) join()  { presenceAdd(1) }
      func (p *LivePresence) leave() { presenceAdd(-1) }
      
      func (p *LivePresence) View() h.H {
      	return h.Div(
      		h.P(h.Class("metric"), p.Tabs.Display()),
      		h.P(h.Class("note"), h.Str("tabs connected to this page, yours included")),
      	)
      }
      

      #Frame size

      A push sends the unit's whole HTML: via re-renders the unit that changed, with no diff, and Datastar morphs it into the page. A render identical to the last sends nothing. A change costs the size of its unit, once for every tab that shows it.

      Measured on a table of 1000 rows, four cells each, about 168 bytes a row:

      LayoutSent per tab, per change
      The rows in a live via.List; one row's status changes168 KB
      The rows plain, in a root that also renders a via.State counter; the counter changes168 KB
      The rows plain in the root; the counter in its own live via.Child; the counter changesabout 100 bytes

      A live State anywhere in a unit makes the whole unit the patch. At 1000 open tabs, one change is 168 MB against 100 KB. When a small part changes often, keep the large part plain and move the small part into its own live Child. A live List sends every row on every change, so cap or page one that can grow.

      Patches are not compressed. The Caddyfile on the deploy page leaves text/event-stream out of encode, because a compressing proxy holds patches back. Size the unit instead.

      #Connection lifecycle

      EventWhat happens
      Idle streamA keepalive frame every 25 seconds, fixed. A failed write is how the server notices a peer that vanished without closing, and the stream then runs its disposers.
      Stalled peerA frame write gives up after 10 seconds and the stream closes. Past via.WithPinnedDeadline (5 s) the tab is logged as pinned, blocked writing to a client that is not reading, and its actions answer 503.
      Blocked handlerA Tick, Listen or action handler running past via.WithPinnedDeadline is logged with its tab and unit type. Everything else on the tab waits behind it, keepalives included, and its actions answer 503.
      Click before the stream connectsThe action waits up to 2 seconds for the stream, then runs; several run in click order. Past 2 seconds it answers 410 and the tab reloads.
      Session written elsewhereA Put or Delete from any request reaches the Tick and Listen handlers of every open tab on the session: at once in this process, on the next keepalive from another process.
      Session rotatedEvery other open tab on the session ends its stream and reloads under the new cookie. The tab whose action rotated keeps its stream.
      Session goneOn the keepalive, a stream whose session another process rotated away, deleted or let expire closes, and the tab reloads.
      Network dropThe browser retries the stream under the same tab id and shows a Reconnecting banner; a click in the gap waits up to 2 seconds for it, then answers 410 and the tab reloads. If the session cookie changed since the page loaded (a Rotate, or a session minted after the load), the retry gets a fresh id and a click in the gap answers 410.
      Stream endsA clean close, from a deploy or r.Shutdown(ctx), shows a Disconnected banner, then probes the page URL with backoff from 0.5 to 8 seconds and reloads once the server answers.
      Action on a gone tabWaits up to 2 seconds, or via.WithPinnedDeadline if that is shorter, for the stream to come back, then answers 410 and the tab reloads once. When via ended the stream itself (Rotate, a session gone, an aborted push), the 410 comes at once.
      Failed actionA 500 from a handler panic, a 503 or a 403 leaves the banner alone: the stream is fine. Datastar fires a datastar-fetch error event on the element that made the request.
      Too many streamsA connect past via.WithMaxSSEConn (10,000 by default) answers 503. So does an action once as many actions, at most 1024, are already waiting for their streams.

      A reconnect is a new connection: OnInit runs again and State starts from what it sets. After a server restart nothing of the old process remains, so a tab gets back whatever your stores and the session hold. Automatic reloads are capped per episode, and probing stops after about two and a half minutes; either way the banner then offers a Reconnect button.

      The banner's state is also on <html data-via-connection> as online, connecting or offline, for styling your own connection UI.