typeStepperstruct{Labelstring// prop: whoever embeds or mounts it sets itByint// prop with a default, filled in by OnInitNvia.State[int]// state: on the server, one per connection}func(s*Stepper)OnInit(ctx*via.Ctx)error{ifs.By==0{s.By=1}returnnil}func(s*Stepper)Add(ctx*via.Ctx){s.N.Set(s.N.Get()+s.By)}func(s*Stepper)View()h.H{returnh.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.
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.
left
right
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
packagedemosimport("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.typeRegionstruct{LabelstringNvia.State[int]}func(r*Region)Bump(ctx*via.Ctx){r.N.Set(r.N.Get()+1)}func(r*Region)View()h.H{returnh.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.typeComposestruct{Left,RightRegion}// 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"returnnil}func(c*Compose)View()h.H{returnh.Div(h.Class("pair"),via.Child(c.Left),via.Child(c.Right))}
typeChatstruct{Draftvia.Signal[string]}typeInboxstruct{ChatChat// Draft is $chat__draft; container #via-i0}typeSplitstruct{Left,RightInbox// $i0__chat__draft, $i1__chat__draft}func(c*Chat)View()h.H{returnh.Input(c.Draft.Bind())}func(i*Inbox)View()h.H{returnh.Div(via.Child(i.Chat))}func(s*Split)View()h.H{returnh.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.
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. That read answers 410 and the page reloads.
Second half: the same three clicks keep working.
The reload keeps the banner dismissed, since the session remembers it. Click bring the banner back to try again.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("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.typedismissalsstruct{musync.Mutexbymap[string]bool}// maxDismissals bounds the map: a visitor forgotten by it sees the banner again.constmaxDismissals=10000func(d*dismissals)get(sidstring)bool{d.mu.Lock()deferd.mu.Unlock()returnd.by[sid]}func(d*dismissals)set(sidstring,vbool){d.mu.Lock()deferd.mu.Unlock()ifd.by==nil||len(d.by)>=maxDismissals{d.by=map[string]bool{}}ifv{d.by[sid]=true}else{delete(d.by,sid)}}// Banner hides itself once dismissed, so it is safe to embed unconditionally.typeBannerstruct{Seen*dismissalshiddenbool}func(b*Banner)OnInit(ctx*via.Ctx)error{b.hidden=b.Seen.get(ctx.Session().ID())returnnil}func(b*Banner)Dismiss(ctx*via.Ctx){b.Seen.set(ctx.Session().Ensure(),true)b.hidden=true}func(b*Banner)View()h.H{returnvia.When(!b.hidden,b.body)}func(b*Banner)body()h.H{returnh.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.typeStampstruct{Atvia.Signal[string]}func(s*Stamp)Read(ctx*via.Ctx){s.At.Set(time.Now().Format("15:04:05"))}func(s*Stamp)View()h.H{returnh.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.typeShiftBrokenstruct{BannerBannerStampStamphiddenbool}func(p*ShiftBroken)OnInit(ctx*via.Ctx)error{p.hidden=p.Banner.Seen.get(ctx.Session().ID())returnnil}func(p*ShiftBroken)View()h.H{returnh.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{returnvia.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.typeShiftFixedstruct{BannerBannerStampStamp}func(p*ShiftFixed)View()h.H{returnh.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.typeShiftPairstruct{BrokenShiftBrokenFixedShiftFixed}funcNewShiftPair()ShiftPair{returnShiftPair{Broken:ShiftBroken{Banner:Banner{Seen:new(dismissals)}},Fixed:ShiftFixed{Banner:Banner{Seen:new(dismissals)}},}}func(p*ShiftPair)View()h.H{returnh.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.typeShiftBrokenstruct{BannerBannerStampStamphiddenbool}func(p*ShiftBroken)OnInit(ctx*via.Ctx)error{p.hidden=p.Banner.Seen.get(ctx.Session().ID())returnnil}func(p*ShiftBroken)View()h.H{returnh.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{returnvia.Child(p.Banner)}
compositions_keys.go
// ShiftFixed always calls Child; Banner decides inside its own View whether// to draw anything. Every key stays put.typeShiftFixedstruct{BannerBannerStampStamp}func(p*ShiftFixed)View()h.H{returnh.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")),)}
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, on a child the empty page does not render, at its first render. Which hooks run depends on the request:
Request
Runs
On instance
GET of the page
OnInit on every unit, each right before its View, so a parent's runs before its children's. The root's PageMeta names the document.
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.533 #12 OnInit GET /v0.9/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 Conditional children, the one plain 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
packagedemosimport("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.varhookLogs=&sessionLog{}typesessionLogstruct{musync.Mutexbymap[string][]string}const(stampFormat="15:04:05.000"hookLogRows=14hookLogSessions=2000)func(s*sessionLog)add(sidstring,lines...string)[]string{s.mu.Lock()defers.mu.Unlock()ifs.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,bstring)int{returnstrings.Compare(a[:len(stampFormat)],b[:len(stampFormat)])})iflen(log)>hookLogRows{log=log[len(log)-hookLogRows:]}s.by[sid]=logreturnslices.Clone(log)}varhookInstancesatomic.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.typeHookLogstruct{// trim: see shared_contract.go.Logvia.List[string]nint64sidstring}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)returnnil}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){ifl.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")returnnil}func(l*HookLog)note(hook,detailstring){line:=fmt.Sprintf("%s #%d %-9s %s",time.Now().Format(stampFormat),l.n,hook,detail)ifl.sid==""{l.Log.Append(line)trim(&l.Log,hookLogRows)return}l.Log.Set(hookLogs.add(l.sid,line))}func(l*HookLog)View()h.H{returnh.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(l.row)),)}func(l*HookLog)row(sstring)h.H{returnh.Li(h.Str(s))}
Hook
Use it to
Rules
OnInit
Load request, session and store data into fields. Register the unit's timers and subscriptions.
typeTodostruct{IDintTitlestring}typeTodosstruct{Itemsvia.List[Todo]}func(t*Todos)Remove(ctx*via.Ctx,idint){fori,x:=ranget.Items.Get(){ifx.ID==id{t.Items.Remove(i)return}}}func(t*Todos)row(xTodo)h.H{id:="todo-"+strconv.Itoa(x.ID)del:=on.Bind(t.Remove,x.ID)returnh.Li(h.ID(id),h.Str(x.Title),h.Button(on.Click(del),h.Str("×")))}func(t*Todos)View()h.H{returnh.Div(h.Ul(t.Items.Each(t.row)),via.When(len(t.Items.Get())==0,t.empty),)}func(t*Todos)empty()h.H{returnh.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.Bind 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.Bind 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.
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.
More slots are more type parameters. C any does not require a View, so a body type without one compiles and panics at Mount, when via renders the page once.
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.
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
typeAvatarstruct{NamestringSizeint}typeProfilestruct{MeAvatar}// a constant prop: in the literalvarme=Profile{Me:Avatar{Size:48},}// request data: in OnInitfunc(p*Profile)OnInit(ctx*via.Ctx,)error{p.Me.Name=lookup(ctx)returnnil}
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.
// 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=tr.Summary.Picks=treturnnil}func(p*Picker)Pick(ctx*via.Ctx,choicestring,){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)returnnil}
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.
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
packagedemosimport("github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on""github.com/go-via/via/topic")varrelayChoices=[]string{"Go","Rust","Zig"}// Relay hands both children one topic.typeRelaystruct{PickerPickerSummarySummary}// Picker publishes without knowing who listens.typePickerstruct{Picks*topic.Topic[string]Sentvia.State[int]}// Summary listens, and its re-render is pushed after each value.typeSummarystruct{Picks*topic.Topic[string]Lastvia.State[string]Countvia.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=tr.Summary.Picks=treturnnil}func(p*Picker)Pick(ctx*via.Ctx,choicestring,){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)returnnil}func(s*Summary)picked(ctx*via.Ctx,choicestring){s.Last.Set(choice)s.Count.Set(s.Count.Get()+1)}func(r*Relay)View()h.H{returnh.Div(h.Class("pair"),via.Child(r.Picker),via.Child(r.Summary))}func(p*Picker)View()h.H{returnh.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(choicestring)h.H{returnh.Button(on.Click(on.Bind(p.Pick,choice)),h.Str(choice))}func(s*Summary)View()h.H{returnh.Div(h.P(h.Str("last pick: "),h.Strong(s.Last.Display())),h.P(h.Class("note"),h.Str("received: "),s.Count.Display()),)}
// Cart is the mounted root.typeCartstruct{Itemsvia.List[string]TotalTotal}typeTotalstruct{Nint}func(c*Cart)Add(ctx*via.Ctx){c.Items.Append("apple")// the push copies Total with Nc.Total.N=len(c.Items.Get())}func(c*Cart)View()h.H{returnh.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.
const User = createContext(null);
<User.Provider value={user}>
<App />
</User.Provider>
function Badge() {
const user = useContext(User);
return <span>{user}</span>;
}
via
typeBadgestruct{Userstring}func(b*Badge)OnInit(ctx*via.Ctx,)error{// set by the WithUser middlewarer:=ctx.Request()b.User,_=r.Context().Value(userKey{}).(string)returnnil}
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.
typeMenustruct{// from the session, in OnInitAdminbool// browser-onlyOpenvia.SignalCS[bool]}func(m*Menu)View()h.H{open:=m.Open.Ref()click:=on.ClickCS(open.Toggle())returnh.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.
typeOrderstruct{Qtyvia.Signal[int]Priceint}// Go, at render: can call anything.func(o*Order)Subtotal()int{returno.Qty.Get()*o.Price}// JS, in the browser, per keystroke.func(o*Order)live()expr.Expr{returnexpr.Rawf("%s * %s",o.Qty.Ref(),expr.Val(o.Price))}func(o*Order)View()h.H{returnh.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.
typeClockstruct{Nowvia.State[string]}func(c*Clock)OnInit(ctx*via.Ctx,)error{// stops when the stream closesctx.Tick(time.Second,c.tick)returnnil}func(c*Clock)tick(ctx*via.Ctx){t:=time.Now().Format("15:04:05")c.Now.Set(t)}
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.Bind.
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.