via

You are reading the docs for v0.8. Read the latest (v0.10)

Compositions

A via page is a tree of Go structs. A struct is a component: fields hold its props and state, methods are its actions, and View renders it.

On this page

#A composition is a struct

component.go
type Stepper struct {
	Label string         // prop: whoever embeds or mounts it sets it
	By    int            // prop with a default, filled in by OnInit
	N     via.State[int] // state: on the server, one per connection
}

func (s *Stepper) OnInit(ctx *via.Ctx) error {
	if s.By == 0 {
		s.By = 1
	}
	return nil
}

func (s *Stepper) Add(ctx *via.Ctx) { s.N.Set(s.N.Get() + s.By) }

func (s *Stepper) View() h.H {
	return h.Div(
		h.Strong(h.Str(s.Label)),
		h.Output(s.N.Display()),
		h.Button(on.Click(s.Add), h.Str("+")),
	)
}

A plain field is a prop. It comes from the literal the struct was built from, which via copies for every new instance: one per plain request, one per stream connection. via.State, via.List and via.Signal fields are state that outlives a request: State and List on the server, for the life of the tab's connection; Signal in the browser, posted with each action. A method taking a via.Ctx is an action, bound with on.Click(s.Add). View takes no ctx and does no I/O: fields in, markup out. It may run several times per request.

#Two instances

Region is one struct with its own State and action. Compose holds two of them as fields and renders each with via.Child. Each gets its own container, its own state and its own action URL.

left0
right0

Try this

  • Click + on left, then on right. The two counts move apart.
  • In the Wire pane, the two POSTs differ only in the child key in their URL.
Wire

Use the demo to see its requests and patches here.

Source
package demos

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

// Region is one region: its own State, its own action, its own container. Two
// of them on a page never collide — via keys a child by its position among its
// parent's Child calls, and that key is the container id, the signal prefix
// and the action address alike.
type Region struct {
	Label string
	N     via.State[int]
}

func (r *Region) Bump(ctx *via.Ctx) { r.N.Set(r.N.Get() + 1) }

func (r *Region) View() h.H {
	return h.Div(h.Class("row"),
		h.Strong(h.Str(r.Label)),
		h.Output(r.N.Display()),
		h.Button(on.Click(r.Bump), h.Str("+")),
	)
}

// Compose holds two of them as plain struct fields. It is itself plain: a live
// unit may not contain another live unit, but a plain one may nest as deep as
// it likes.
type Compose struct{ Left, Right Region }

// OnInit seeds what a parent would normally put in the child's literal;
// Compose is a zero-value field of its page, so there is no literal to seed
// from.
func (c *Compose) OnInit(ctx *via.Ctx) error {
	c.Left.Label, c.Right.Label = "left", "right"
	return nil
}

func (c *Compose) View() h.H {
	return h.Div(h.Class("pair"), via.Child(c.Left), via.Child(c.Right))
}

#Children and keys

names.go
type Chat struct {
	Draft via.Signal[string]
}

type Inbox struct {
	Chat Chat // Draft is $chat__draft; container #via-i0
}

type Split struct {
	Left, Right Inbox // $i0__chat__draft, $i1__chat__draft
}

func (c *Chat) View() h.H  { return h.Input(c.Draft.Bind()) }
func (i *Inbox) View() h.H { return h.Div(via.Child(i.Chat)) }
func (s *Split) View() h.H { return h.Div(via.Child(s.Left), via.Child(s.Right)) }

via.Child(p.Chat) renders a field as its own unit, with its own container, OnInit and actions. The argument must be a field selector: Child copies the child at every render, and a composite literal there would re-seed it each time. Value fields are copied, so each connection has its own; pointer fields are shared, which is how two children reach one store.

A child's key is its position among its parent's Child calls, joined to the parent's key: 0, 0-1. The key is the container id (#via-i0-1) and the address in the child's action URLs. Signal names come from the field path instead: the child's field name and a double underscore, so Draft inside Inbox.Chat is $chat__draft. Two fields of the same type cannot be told apart by type, so their signals take the position: $i0__chat__draft.

#Conditional children shift keys

Both halves hold a banner and a clock. The first wraps the banner's Child in via.When; the second always calls Child and lets the banner draw nothing once dismissed. Dismissing re-renders only the banner, so the page keeps the clock's old action URL. In the first half the server's next render has one Child fewer ahead of the clock, the clock's position drops from 1 to 0, and the old URL points at nothing.

When around the Child

A banner above the clock.

When inside the child

A banner above the clock.

Try this

  • First half: read the clock, dismiss the banner, read the clock again. The Wire pane shows a 410.
  • Second half: the same three clicks keep working.
  • Click bring the banner back: it re-renders the whole half with fresh URLs, which repairs the first one.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"sync"
	"time"

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

// dismissals is which visitors closed a banner, keyed by session id. It stands
// in for any store that changes while a page is open.
type dismissals struct {
	mu sync.Mutex
	by map[string]bool
}

// maxDismissals bounds the map: a visitor forgotten by it sees the banner again.
const maxDismissals = 10000

func (d *dismissals) get(sid string) bool {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.by[sid]
}

func (d *dismissals) set(sid string, v bool) {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.by == nil || len(d.by) >= maxDismissals {
		d.by = map[string]bool{}
	}
	if v {
		d.by[sid] = true
	} else {
		delete(d.by, sid)
	}
}

// Banner hides itself once dismissed, so it is safe to embed unconditionally.
type Banner struct {
	Seen   *dismissals
	hidden bool
}

func (b *Banner) OnInit(ctx *via.Ctx) error {
	b.hidden = b.Seen.get(ctx.Session().ID())
	return nil
}

func (b *Banner) Dismiss(ctx *via.Ctx) {
	b.Seen.set(ctx.Session().Ensure(), true)
	b.hidden = true
}

func (b *Banner) View() h.H { return via.When(!b.hidden, b.body) }

func (b *Banner) body() h.H {
	return h.P(h.Class("panel", "row"),
		h.Span(h.Str("A banner above the clock.")),
		h.Button(on.Click(b.Dismiss), h.Str("dismiss")),
	)
}

// Stamp is plain: each click is a fresh request, addressed by Stamp's key.
type Stamp struct{ At via.Signal[string] }

func (s *Stamp) Read(ctx *via.Ctx) { s.At.Set(time.Now().Format("15:04:05")) }

func (s *Stamp) View() h.H {
	return h.Div(h.Class("row"),
		h.Button(on.Click(s.Read), h.Str("read the clock")),
		h.Output(s.At.Display()),
	)
}

// ShiftBroken wraps the Child in a When. Dismissing drops Banner from the
// server's tree, so Stamp's key moves from 1 to 0, but the page still holds
// Stamp's button with key 1 in its URL.
type ShiftBroken struct {
	Banner Banner
	Stamp  Stamp
	hidden bool
}

func (p *ShiftBroken) OnInit(ctx *via.Ctx) error {
	p.hidden = p.Banner.Seen.get(ctx.Session().ID())
	return nil
}

func (p *ShiftBroken) View() h.H {
	return h.Div(
		h.P(h.Class("note"), h.Str("When around the Child")),
		via.When(!p.hidden, p.banner),
		via.Child(p.Stamp),
		h.Button(on.Click(p.Restore), h.Str("bring the banner back")),
	)
}

func (p *ShiftBroken) banner() h.H { return via.Child(p.Banner) }

func (p *ShiftBroken) Restore(ctx *via.Ctx) {
	p.Banner.Seen.set(ctx.Session().ID(), false)
	p.hidden = false
}

// ShiftFixed always calls Child; Banner decides inside its own View whether
// to draw anything. Every key stays put.
type ShiftFixed struct {
	Banner Banner
	Stamp  Stamp
}

func (p *ShiftFixed) View() h.H {
	return h.Div(
		h.P(h.Class("note"), h.Str("When inside the child")),
		via.Child(p.Banner),
		via.Child(p.Stamp),
		h.Button(on.Click(p.Restore), h.Str("bring the banner back")),
	)
}

func (p *ShiftFixed) Restore(ctx *via.Ctx) { p.Banner.Seen.set(ctx.Session().ID(), false) }

// ShiftPair holds both variants under one parent, so one Wire pane follows
// the two halves' traffic. Each half gets its own store, so dismissing one
// banner leaves the other alone.
type ShiftPair struct {
	Broken ShiftBroken
	Fixed  ShiftFixed
}

func NewShiftPair() ShiftPair {
	return ShiftPair{
		Broken: ShiftBroken{Banner: Banner{Seen: new(dismissals)}},
		Fixed:  ShiftFixed{Banner: Banner{Seen: new(dismissals)}},
	}
}

func (p *ShiftPair) View() h.H {
	return h.Div(h.Class("pair"), via.Child(p.Broken), via.Child(p.Fixed))
}
compositions_keys.go
// ShiftBroken wraps the Child in a When. Dismissing drops Banner from the
// server's tree, so Stamp's key moves from 1 to 0, but the page still holds
// Stamp's button with key 1 in its URL.
type ShiftBroken struct {
	Banner Banner
	Stamp  Stamp
	hidden bool
}

func (p *ShiftBroken) OnInit(ctx *via.Ctx) error {
	p.hidden = p.Banner.Seen.get(ctx.Session().ID())
	return nil
}

func (p *ShiftBroken) View() h.H {
	return h.Div(
		h.P(h.Class("note"), h.Str("When around the Child")),
		via.When(!p.hidden, p.banner),
		via.Child(p.Stamp),
		h.Button(on.Click(p.Restore), h.Str("bring the banner back")),
	)
}

func (p *ShiftBroken) banner() h.H { return via.Child(p.Banner) }
compositions_keys.go
// ShiftFixed always calls Child; Banner decides inside its own View whether
// to draw anything. Every key stays put.
type ShiftFixed struct {
	Banner Banner
	Stamp  Stamp
}

func (p *ShiftFixed) View() h.H {
	return h.Div(
		h.P(h.Class("note"), h.Str("When inside the child")),
		via.Child(p.Banner),
		via.Child(p.Stamp),
		h.Button(on.Click(p.Restore), h.Str("bring the banner back")),
	)
}

#Lifecycle hooks

A hook is a method with a fixed name and signature; via calls it if the type has it. OnInit and OnReload take a *via.Ctx and return an error; View and PageMeta take nothing. A hook name with the wrong signature panics at via.Mount, or at a child's first render. Which hooks run depends on the request:

RequestRunsOn instance
GET of the pageOnInit on every unit, each right before its View, so a parent's runs before its children's. The root's PageMeta names the document.fresh, dropped after the response
Stream connect, when a unit is liveOnInit on every unit again. Then each via.Ctx.Listen subscribes and each via.Ctx.OnConnect function runs.fresh, kept for the connection
Action on a plain unitOnInit on every unit of the page, the handler, OnReload on the acted unit, then that unit's View.fresh, dropped after the response
Action on a live unitThe handler, OnReload, View; the patch goes down the stream. No OnInit.the connection's
Tick or Listen valueThe handler, then View.the connection's
Stream closesvia.Ctx.OnDispose functions.the connection's

#Hook log

Each line is one hook call, numbered by the instance it ran on. The log is kept per session, so lines from instances that are gone stay visible. Until you have a session each instance shows only its own lines; the first action here mints one.

  • 06:55:15.038 #6 OnInit GET /v0.8/compositions

Try this

  • Run an action: the action and OnReload run on the stream's instance, with no OnInit.
  • Reload the page: the old stream's OnDispose, and OnInit on two new instances, one for the GET and one for the stream.
  • Click a button in another card, then run an action here: that POST ran this OnInit too, on a throwaway instance.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"fmt"
	"slices"
	"strings"
	"sync"
	"sync/atomic"
	"time"

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

// hookLogs outlives every instance: the GET's, the stream's, and the one a
// closed stream disposed of each write here, and none of them lives long enough
// to see the others' lines.
var hookLogs = &sessionLog{}

type sessionLog struct {
	mu sync.Mutex
	by map[string][]string
}

const (
	stampFormat     = "15:04:05.000"
	hookLogRows     = 14
	hookLogSessions = 2000
)

func (s *sessionLog) add(sid string, lines ...string) []string {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.by == nil || len(s.by) >= hookLogSessions {
		s.by = map[string][]string{}
	}
	// Instances append out of order (a request that ran before the session
	// existed hands its lines over later), and every line starts with its time.
	log := append(s.by[sid], lines...)
	slices.SortStableFunc(log, func(a, b string) int { return strings.Compare(a[:len(stampFormat)], b[:len(stampFormat)]) })
	if len(log) > hookLogRows {
		log = log[len(log)-hookLogRows:]
	}
	s.by[sid] = log
	return slices.Clone(log)
}

var hookInstances atomic.Int64

// HookLog writes one line per hook, tagged with the instance it ran on. Until
// the visitor has a session there is nowhere to keep lines across instances, so
// each instance shows only its own; the first action mints one.
type HookLog struct {
	Log via.List[string]
	n   int64
	sid string
}

func (l *HookLog) OnInit(ctx *via.Ctx) error {
	l.n = hookInstances.Add(1)
	l.sid = ctx.Session().ID()
	l.note("OnInit", ctx.Request().Method+" "+ctx.Request().URL.Path)
	ctx.OnConnect(l.connected)
	ctx.OnDispose(l.disposed)
	return nil
}

func (l *HookLog) connected() { l.note("OnConnect", "stream open") }
func (l *HookLog) disposed()  { l.note("OnDispose", "stream closed") }

func (l *HookLog) Act(ctx *via.Ctx) {
	if l.sid == "" {
		l.sid = ctx.Session().Ensure()
		hookLogs.add(l.sid, l.Log.Get()...)
	}
	l.note("action", "Act")
}

func (l *HookLog) OnReload(ctx *via.Ctx) error {
	l.note("OnReload", "after Act")
	return nil
}

func (l *HookLog) note(hook, detail string) {
	line := fmt.Sprintf("%s  #%d  %-9s  %s", time.Now().Format(stampFormat), l.n, hook, detail)
	if l.sid == "" {
		l.Log.Append(line)
		trim(&l.Log, hookLogRows)
		return
	}
	l.Log.Set(hookLogs.add(l.sid, line))
}

func (l *HookLog) View() h.H {
	return h.Div(
		h.Button(on.Click(l.Act), h.Str("run an action")),
		h.Ul(h.Class("loglist"), h.TabIndex(0), h.Role("log"), h.Aria("label", "Hook calls"),
			l.Log.Each(func(s string) h.H { return h.Li(h.Str(s)) })),
	)
}
HookUse it toRules
OnInitLoad request, session and store data into fields. Register the unit's timers and subscriptions.via.Ctx.Tick, via.Ctx.Listen, OnConnect, OnDispose and via.State.Track register only here; anywhere else they log and do nothing. Return via.ErrNotFound for a 404, via.ErrForbidden for a 403.
OnReloadRe-read what the action changed, before the render that answers it.Runs on the acted unit only, and not after a Redirect. Tick and Listen are ignored here.
ViewRender.No ctx, no I/O, no side effects. It can run more than once per request.
PageMetaName the document: title, description, via.Meta assets.Read on the mounted root only. Pure: it is read at Mount and on every document render.
OnConnectAcquire: join a room, claim a slot.Runs once when the stream opens, after every Listen has subscribed. A Set in fn is pushed.
OnDisposeRelease what OnConnect took.Runs when the stream closes. Only a live unit has a stream to close.

#Conditionals and lists

lists.go
type Todo struct {
	ID    int
	Title string
}

type Todos struct {
	Items via.List[Todo]
}

func (t *Todos) Remove(ctx *via.Ctx, id int) {
	for i, x := range t.Items.Get() {
		if x.ID == id {
			t.Items.Remove(i)
			return
		}
	}
}

func (t *Todos) row(x Todo) h.H {
	id := "todo-" + strconv.Itoa(x.ID)
	del := on.WithArg(t.Remove, x.ID)
	return h.Li(h.ID(id),
		h.Str(x.Title),
		h.Button(on.Click(del),
			h.Str("×")))
}

func (t *Todos) View() h.H {
	return h.Div(
		h.Ul(t.Items.Each(t.row)),
		via.When(len(t.Items.Get()) == 0, t.empty),
	)
}

func (t *Todos) empty() h.H { return h.P(h.Str("Nothing to do.")) }

via.When(cond, build) calls build only when cond holds. via.Each and via.List.Each call a row method per item. A row is markup, not a unit: its action is a method on the list's owner, and on.WithArg carries the row's id with the click. A Signal inside a slice element has no field to be named by, and rendering it panics.

Give each row a stable h.ID. Datastar morphs a re-rendered list by position, which is right for an append-only log and wrong after a delete or a reorder: without ids, the rows after the removed one are patched in place from their neighbours.

The render that handles an action decides what it may do. An action answers 410 Gone when that render does not contain it: its When branch is closed, its row is gone, its on.WithArg value is one no row rendered, or its child key holds nothing or a unit of another type. The check runs before the handler, so a handler only sees arguments the render offered.

#Layouts and slots

layout.go
type Shell[C any] struct {
	Title string
	Body  C
}

func (s *Shell[C]) PageMeta() via.Meta { return via.Meta{Title: s.Title} }

func (s *Shell[C]) View() h.H {
	return h.Div(
		h.Header(h.H1(h.Str(s.Title))),
		h.Main(via.Child(s.Body)),
	)
}
layout.go
func MountShells(r *via.Router) {
	vol := Stepper{Label: "volume"}
	via.Mount(r, "/settings",
		Shell[Stepper]{
			Title: "Settings",
			Body:  vol,
		})
	via.Mount(r, "/inbox",
		Shell[TwoColumn[Profile, Inbox]]{
			Title: "Inbox",
		})
}

A layout is a generic struct whose type parameter is its body. Shell[Stepper] and Shell[TwoColumn[Profile, Inbox]] are distinct types, and each via.Mount gets its own. The body renders as a child, so its signals are named body__…. Only the mounted root's PageMeta counts, so the shell names the page; a PageMeta on the body is ignored and logged.

layout.go
type TwoColumn[S, M any] struct {
	Side S
	Main M
}

func (t *TwoColumn[S, M]) View() h.H {
	return h.Div(h.Class("cols"),
		h.Aside(via.Child(t.Side)),
		h.Section(via.Child(t.Main)),
	)
}

More slots are more type parameters. C any does not require a View, so a body type without one compiles and panics on its first render.

#Coming from React, Vue or Svelte

Each pattern below is a JS framework idiom on the left and the via version on the right. The JavaScript is for comparison only; the Go compiles as shown.

#Components and props

React
function Avatar({ name, size }) {
  return (
    <span>
      {name}<small>{size}px</small>
    </span>
  );
}

// a constant prop, and request data
<Avatar size={48} name={user.name} />
via
type Avatar struct {
	Name string
	Size int
}

type Profile struct{ Me Avatar }

// a constant prop: in the literal
var me = Profile{
	Me: Avatar{Size: 48},
}

// request data: in OnInit
func (p *Profile) OnInit(
	ctx *via.Ctx,
) error {
	p.Me.Name = lookup(ctx)
	return nil
}

Props are fields. Constants go in the literal the parent is built from; request data goes in the parent's OnInit, which runs before Child copies the child.

#Lists with keys

React
todos.map(t => (
  <li key={t.id}>
    {t.title}
    <button
      onClick={() => remove(t.id)}>
      ×
    </button>
  </li>
))
via
func (t *Todos) row(x Todo) h.H {
	id := "todo-" + strconv.Itoa(x.ID)
	del := on.WithArg(t.Remove, x.ID)
	return h.Li(h.ID(id),
		h.Str(x.Title),
		h.Button(on.Click(del),
			h.Str("×")))
}

key becomes the row's id, and the closure over t.id becomes on.WithArg.

#Events up

React
function Picker({ onPick }) {
  return (
    <button
      onClick={() => onPick("Go")}>
      Go
    </button>
  );
}

function Relay() {
  const [last, setLast] =
    useState("");
  return (
    <>
      <Picker onPick={setLast} />
      <p>last pick: {last}</p>
    </>
  );
}
via
// A new topic per render: each tab's
// stream gets its own.
func (r *Relay) OnInit(
	ctx *via.Ctx,
) error {
	t := topic.New[string]()
	r.Picker.Picks = t
	r.Summary.Picks = t
	return nil
}

func (p *Picker) Pick(
	ctx *via.Ctx, choice string,
) {
	p.Sent.Set(p.Sent.Get() + 1)
	p.Picks.Publish(choice)
}

func (s *Summary) OnInit(
	ctx *via.Ctx,
) error {
	ctx.Listen(s.Picks, s.picked)
	return nil
}

A child's action re-renders only that child, so a callback into the parent would run and leave the parent's markup as it was. If the parent owns the state, put the button in the parent's View. If two units must talk, one publishes on a topic.Topic and the other subscribes with via.Ctx.Listen. Here the parent makes the topic in OnInit, so each tab's stream gets its own.

#Relay

The picker and the summary are siblings that share one topic and nothing else.

sent: 0

last pick:

received: 0

Try this

  • Pick a language. The Wire pane shows one POST for the picker, then an SSE patch for the summary.
Wire

Use the demo to see its requests and patches here.

Source
package demos

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

var relayChoices = []string{"Go", "Rust", "Zig"}

// Relay hands both children one topic.
type Relay struct {
	Picker  Picker
	Summary Summary
}

// Picker publishes without knowing who listens.
type Picker struct {
	Picks *topic.Topic[string]
	Sent  via.State[int]
}

// Summary listens, and its re-render is pushed after each value.
type Summary struct {
	Picks *topic.Topic[string]
	Last  via.State[string]
	Count via.State[int]
}

// A new topic per render: each tab's
// stream gets its own.
func (r *Relay) OnInit(
	ctx *via.Ctx,
) error {
	t := topic.New[string]()
	r.Picker.Picks = t
	r.Summary.Picks = t
	return nil
}

func (p *Picker) Pick(
	ctx *via.Ctx, choice string,
) {
	p.Sent.Set(p.Sent.Get() + 1)
	p.Picks.Publish(choice)
}

func (s *Summary) OnInit(
	ctx *via.Ctx,
) error {
	ctx.Listen(s.Picks, s.picked)
	return nil
}

func (s *Summary) picked(ctx *via.Ctx, choice string) {
	s.Last.Set(choice)
	s.Count.Set(s.Count.Get() + 1)
}

func (r *Relay) View() h.H {
	return h.Div(h.Class("pair"), via.Child(r.Picker), via.Child(r.Summary))
}

func (p *Picker) View() h.H {
	return h.Div(
		h.Div(h.Class("row"), via.Each(relayChoices, p.button)),
		h.P(h.Class("note"), h.Str("sent: "), p.Sent.Display()),
	)
}

func (p *Picker) button(choice string) h.H {
	return h.Button(on.Click(on.WithArg(p.Pick, choice)), h.Str(choice))
}

func (s *Summary) View() h.H {
	return h.Div(
		h.P(h.Str("last pick: "), h.Strong(s.Last.Display())),
		h.P(h.Class("note"), h.Str("received: "), s.Count.Display()),
	)
}

#Lifting state

React
function Cart() {
  const [items, setItems] =
    useState([]);
  const add = () =>
    setItems([...items, "apple"]);
  return (
    <>
      <button onClick={add}>
        add
      </button>
      <ul>{items.map(row)}</ul>
      <Total n={items.length} />
    </>
  );
}
via
// Cart is the mounted root.
type Cart struct {
	Items via.List[string]
	Total Total
}

type Total struct{ N int }

func (c *Cart) Add(ctx *via.Ctx) {
	c.Items.Append("apple")
	// the push copies Total with N
	c.Total.N = len(c.Items.Get())
}

func (c *Cart) View() h.H {
	return h.Div(
		h.Button(on.Click(c.Add),
			h.Str("add")),
		h.Ul(c.Items.Each(c.row)),
		via.Child(c.Total),
	)
}

The owner holds the state and sets the child's field in its action; the push that follows copies the child with the new value. Cart must be the mounted root: a live child may not render children of its own. For state that many tabs share, keep it in a store and give each unit a via.StateTrack on its topic; see Live state.

#Context and providers

React
const User = createContext(null);

<User.Provider value={user}>
  <App />
</User.Provider>

function Badge() {
  const user = useContext(User);
  return <span>{user}</span>;
}
via
type Badge struct{ User string }

func (b *Badge) OnInit(
	ctx *via.Ctx,
) error {
	// set by the WithUser middleware
	r := ctx.Request()
	b.User, _ = r.Context().
		Value(userKey{}).(string)
	return nil
}

via has no provider tree. What a provider carries lives in one of three places: the request, whose context middleware can extend and any unit reads through via.Ctx.Request; the session, which via.Ctx.Session resolves to the same one in every unit of a request; or a pointer in the literal you pass to via.Mount, handed down field by field. Nothing propagates on its own: a child sees what its parent's literal or OnInit gave it.

#Slots and children

React
function Shell({ title, children }) {
  return (
    <>
      <header>
        <h1>{title}</h1>
      </header>
      <main>{children}</main>
    </>
  );
}

<Shell title="Settings">
  <Stepper label="volume" />
</Shell>
via
vol := Stepper{Label: "volume"}
via.Mount(r, "/settings",
	Shell[Stepper]{
		Title: "Settings",
		Body:  vol,
	})

children is a type parameter; see Layouts and slots.

#Conditional rendering

React
<nav>
  <button
    onClick={() => setOpen(!open)}>
    menu
  </button>
  {open && (
    <ul>
      <li>profile</li>
      {isAdmin && <PurgeItem />}
    </ul>
  )}
</nav>
via
type Menu struct {
	// from the session, in OnInit
	Admin bool
	// browser-only
	Open via.SignalCS[bool]
}

func (m *Menu) View() h.H {
	open := m.Open.Ref()
	click := on.ClickCS(open.Toggle())
	return h.Nav(
		h.Button(click, h.Str("menu")),
		h.Ul(h.DataShow(open),
			h.Li(h.Str("profile")),
			via.When(m.Admin, m.item),
		),
	)
}

What the user toggles lives in the browser: a via.SignalCS and h.DataShow, with no request. What decides access is server data read in OnInit, in a When: a closed branch binds nothing, so its actions answer 410.

#Derived values

React / Vue
// React
const subtotal = useMemo(
  () => qty * price, [qty, price]);

// Vue
const subtotal = computed(
  () => qty.value * price);
via
type Order struct {
	Qty   via.Signal[int]
	Price int
}

// Go, at render: can call anything.
func (o *Order) Subtotal() int {
	return o.Qty.Get() * o.Price
}

// JS, in the browser, per keystroke.
func (o *Order) live() expr.Expr {
	return expr.Rawf("%s * %s",
		o.Qty.Ref(),
		expr.Val(o.Price))
}

func (o *Order) View() h.H {
	return h.Div(
		h.Input(o.Qty.Bind()),
		h.Str(o.Subtotal()),
		h.Span(h.DataText(o.live())),
	)
}

A method is a computed value: View calls it at render, and it can reach anything Go can. For a value that follows typing with no request, build an expr.Expr over the signals; the browser re-evaluates it. See Signals.

#Effects

React
useEffect(() => {
  const id = setInterval(
    () => setNow(new Date()), 1000);
  return () => clearInterval(id);
}, []);
via
type Clock struct {
	Now via.State[string]
}

func (c *Clock) OnInit(
	ctx *via.Ctx,
) error {
	// stops when the stream closes
	ctx.Tick(time.Second, c.tick)
	return nil
}

func (c *Clock) tick(ctx *via.Ctx) {
	t := time.Now().Format("15:04:05")
	c.Now.Set(t)
}

OnInit is where the effect is registered: via.Ctx.Tick and via.Ctx.Listen stop on their own when the stream closes. For a resource of your own, acquire it in via.Ctx.OnConnect and release it in via.Ctx.OnDispose.

#Limits

  • Client-side state is signals only: via.Signal and via.SignalCS. There is no component instance in the browser, and no hook runs there.
  • Anything that needs Go is a round trip: every action is a POST, and the answer is a patch. Typing, toggling and derived text can stay in the browser; validation that needs the database cannot.
  • A child's action re-renders that child only. Units that must react to each other share a topic.
  • A list row is markup, not a unit: no per-row signals, and per-row actions go through on.WithArg.
  • Keys are positions, so a Child's presence must not change while the page is open.
  • A live unit may not contain another live unit, and a live child may not call Child at all. Keep the live units side by side under a plain parent. Both rules panic at render:
panic
via: via.Child: a live unit cannot sit inside another live unit —
  embed it directly from a plain ancestor instead

via: via.Child: a live child's View must not call Child —
  keep a live child's View flat

For UI whose state is mostly in the browser (a map, a chart you pan, an editor), mount a JavaScript island and talk to it through signals; see Islands.