An action is a method on your composition. An event in the browser POSTs to it, the method changes state, and via answers with patches for what changed. There is no route to name and no JSON to decode.
on.Click takes the method value c.Inc. via addresses it by the method's name and the receiver's place in the page, so the POST goes to this child alone and the answer patches this region. The other demos on the page are untouched.
Try this
Click + and read the POST's URL in the Wire pane: /actions/_via/a/{child}/{id}.
Reload the page: the count starts at 0 again, because a State lives on this tab's connection.
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")// Counter is the smallest live unit: rendering a State is what earns this// child its own SSE stream, and a click patches only this region.typeCounterstruct{Nvia.State[int]}func(c*Counter)Inc(ctx*via.Ctx){c.N.Set(c.N.Get()+1)}func(c*Counter)Dec(ctx*via.Ctx){c.N.Set(c.N.Get()-1)}func(c*Counter)View()h.H{returnh.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("+")),)}
Every DOM event binds the same way: on.Change, on.Keydown, on.Submit, and on.Event for an event without its own function. The …CS twins, such as on.ClickCS, run an expression in the browser and post nothing; they are on Signals.
typeTodosstruct{Itemsvia.List[Todo]}// Argless: bind the method value, no & at the call site.func(t*Todos)Clear(ctx*via.Ctx){t.Items.Set(nil)}// With an argument: the second parameter's type is the arg's type.func(t*Todos)Delete(ctx*via.Ctx,idint){items:=t.Items.Get()fori,it:=rangeitems{ifit.ID==id{t.Items.Remove(i)return}}}func(t*Todos)row(itTodo)h.H{returnh.Li(h.Span(h.Str(it.Title)),h.Button(on.Click(on.Bind(t.Delete,it.ID)),h.Str("×")),)}func(t*Todos)View()h.H{returnh.Div(h.Ul(t.Items.Each(t.row)),h.Button(on.Click(t.Clear),h.Str("Clear")),)}
Handler
Result
func (t *T) M(ctx *via.Ctx)
Bind with on.Click(t.M).
func (t *T) M(ctx *via.Ctx, v V)
Bind with on.Click(on.Bind(t.M, v)); see Arguments below.
A return value, no Ctx, a second argument without on.Bind
Does not compile: the binders accept func(*via.Ctx) or an on.Bound only.
A func literal in a loop or an Each row
Compiles, then panics at Mount, or at the first render if the empty value has no rows: every copy has the same Go name, so via cannot tell them apart. Use a method and on.Bind.
A method taken through an interface field, or a value receiver whose type sits at two fields or at none
Compiles, then panics at Mount, or at the first render if the empty value does not reach it: via cannot tell which value the method belongs to. Give the method a pointer receiver and hold the concrete type in the field; a value receiver on the page, or on a type only one field holds, works.
An action returns nothing. Report a failure the user can fix by setting state, such as an error Signal; a panic is a 500. Mount checks the lifecycle hooks' signatures, not actions': the two rows above are the only action checks, and a render has to reach the binding to run them.
An action's id is the method's Go name plus the path of the field it was called on, such as Stats.A. A deploy that reorders fields keeps every button's id; one that renames a field answers 410 to a tab still holding the old name.
func(t*Thread)Reply(ctx*via.Ctx){user,ok:=ctx.Session().Get[User]()if!ok{ctx.Redirect("/login")return}id:=ctx.Param[int]("id")// mounted at /thread/{id}text:=strings.TrimSpace(t.Draft.Get())iftext==""{t.Error.Set("Write something first.")return}iferr:=t.store.AddReply(ctx.Context(),id,user.Name,text);err!=nil{t.Error.Set("Could not save the reply.")return}t.Draft.Set("")t.Error.Set("")}
The same, to another site: an OAuth provider, a payment page.
The rest is your composition: via.Signal.Set, via.State.Set, via.List.Append, or a plain field, and the render that answers the action shows it. There is no response writer; a download is a separate net/http handler that the page links to. via.Ctx.Tick and via.Ctx.Listen register only in OnInit; in an action they log and do nothing. To re-read a store after the action wrote to it, implement OnReload, which is covered on Compositions.
on.Bind(v.Cast, r.Option) puts the row's option index in the action URL as JSON, and Cast receives it as an int. Dispatch accepts only an argument that the render before it bound for that handler, and answers 410 to any other before Cast runs, so the handler can index with it. The tallies are shared with everyone reading this, hence the cap.
Go
Rust
Zig
Elixir
Try this
Vote for Go, then expand the POST in the Wire pane: its URL ends in ?a=0.
Vote in a second tab. This tab shows the new tally on its next vote, not before: nothing pushes it.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("sync""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on")// VoteRow is one poll row. Option is the row's identity — it rides with the// click, so a vote lands on the option clicked and not on a slot.typeVoteRowstruct{OptionintLabelstringCountint}varvoteOptions=[]string{"Go","Rust","Zig","Elixir"}// votes is shared by every visitor, which is why Vote is rate limited.varvotes=struct{musync.Mutexcounts[]int}{counts:make([]int,len(voteOptions))}// The tallies have no topic, so a change is not pushed: an open tab redraws on// its next vote or reload rather than at the moment of reset.funcresetVotes(){votes.mu.Lock()clear(votes.counts)votes.mu.Unlock()}funcvoteRows()[]VoteRow{votes.mu.Lock()defervotes.mu.Unlock()rows:=make([]VoteRow,len(voteOptions))fori,label:=rangevoteOptions{rows[i]=VoteRow{Option:i,Label:label,Count:votes.counts[i]}}returnrows}// Vote is a per-row action: each button carries its own option index, and the// handler receives it as a typed parameter.typeVotestruct{// Limiter: see shared_contract.go.LimLimiterRowsvia.List[VoteRow]Noticevia.State[string]}// NewVote takes the limiter the page owns; a nil one is a wiring mistake, so// it fails here rather than on the first render.funcNewVote(limLimiter)Vote{iflim==nil{panic("demos: NewVote: Vote.Lim must not be nil")}returnVote{Lim:lim}}func(v*Vote)OnInit(ctx*via.Ctx)error{v.Rows.Set(voteRows())returnnil}func(v*Vote)Cast(ctx*via.Ctx,optionint){if!v.Lim.Allow(ctx){v.Notice.Set("Slow down — the tallies are shared, so this demo takes 30 votes a minute.")return}votes.mu.Lock()// Indexing on a client value is safe: only an arg this connection's own// render bound is dispatchable, so option is one of the rendered rows.votes.counts[option]++votes.mu.Unlock()v.Notice.Set("")v.Rows.Set(voteRows())}func(v*Vote)row(rVoteRow)h.H{returnh.Li(h.Class("row"),h.Button(on.Click(on.Bind(v.Cast,r.Option)),h.Str("vote")),h.Span(h.Str(r.Label)),h.Output(h.Str(r.Count)),)}func(v*Vote)View()h.H{returnh.Div(h.Ul(h.Class("tally"),v.Rows.Each(v.row)),h.P(h.Class("notice"),v.Notice.Display()),)}
on.Submit on a <form> stops the native submit and posts the tab's signals as JSON, so the text box is a bound Signal and Add reads it with Get. The answer is a patch: the note appears, Set("") empties the box, and focus and scroll stay where they were.
Try this
Submit an empty box: the error arrives as a signal patch.
Add six notes: the list keeps the last five, and the page never reloads.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("strings""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on")// Note submits in place: on.Submit posts the tab's signals as JSON, so the// field is a bound Signal, not a form value, and the answer patches this// region instead of loading a new page.typeNotestruct{Draftvia.Signal[string]Errvia.Signal[string]// trim: see shared_contract.go.Notesvia.List[string]}func(n*Note)Add(ctx*via.Ctx){text:=strings.TrimSpace(n.Draft.Get())iftext==""{n.Err.Set("Write something first.")return}iflen([]rune(text))>80{n.Err.Set("80 characters at most.")return}n.Notes.Append(text)trim(&n.Notes,5)n.Draft.Set("")n.Err.Set("")}func(n*Note)View()h.H{returnh.Div(h.Form(h.Class("row"),on.Submit(n.Add),h.Input(n.Draft.Bind(),h.Placeholder("a note"),h.AutoComplete("off")),h.Button(h.Type("submit"),h.Str("Add")),),h.Small(h.Class("err"),n.Err.Display()),h.Ul(n.Notes.Each(n.row)),)}func(n*Note)row(sstring)h.H{returnh.Li(h.Str(s))}
via.PostForm renders a native multipart form, so the submit is a real navigation and the handler reads fields with ctx.Request().FormValue. Validation is server-side, and the page comes back with the errors and whatever was typed.
Try this
Submit with one letter in Name: the page reloads with the error and your input kept.
Sign up with valid input: the counter above is back to 0, because the whole document was replaced.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("strings""github.com/go-via/via""github.com/go-via/via/h")// Signup validates on the server. via.PostForm renders a native multipart// form, so the submit is an ordinary navigation: the handler reads the fields// with stdlib and the whole page is rendered again from what it left here.//// The errors are plain fields, not State: a navigation opens a new connection,// which would reseed a State before the reader ever saw it. Anything that must// outlive the submit goes in the session or a store; here it need only survive// into the response this very request renders.typeSignupstruct{name,emailstringnameErr,mailErr,thanksstring}func(s*Signup)Submit(ctx*via.Ctx){s.name=strings.TrimSpace(ctx.Request().FormValue("name"))s.email=strings.TrimSpace(ctx.Request().FormValue("email"))s.nameErr,s.mailErr,s.thanks="","",""iflen([]rune(s.name))<2{s.nameErr="Two characters at least."}if!strings.Contains(s.email,"@"){s.mailErr="That is not an address."}ifs.nameErr!=""||s.mailErr!=""{return// no Redirect: the page comes back with the errors and the input}s.thanks="Welcome, "+s.name+"."s.name,s.email="",""}func(s*Signup)View()h.H{returnvia.PostForm(s.Submit,h.Label(h.Str("Name"),h.Input(h.Name("name"),h.Value(s.name),h.AutoComplete("off")),h.Small(h.Class("err"),h.Str(s.nameErr)),),h.Label(h.Str("Email"),h.Input(h.Name("email"),h.Value(s.email),h.AutoComplete("off")),h.Small(h.Class("err"),h.Str(s.mailErr)),),h.Button(h.Type("submit"),h.Str("Sign up")),h.P(h.Class("notice"),h.Str(s.thanks)),)}
typeProfilestruct{Namevia.Signal[string]Countryvia.Signal[string]`via:"init=\"pt\""`Regionvia.Signal[string]errstring}func(p*Profile)CountryChanged(ctx*via.Ctx){p.Region.Set("")}func(p*Profile)Save(ctx*via.Ctx){r:=ctx.Request()p.Name.Set(strings.TrimSpace(r.FormValue("name")))p.Country.Set(r.FormValue("country"))p.Region.Set(r.FormValue("region"))ifp.Name.Get()==""{p.err="Name is required."}}func(p*Profile)View()h.H{returnvia.PostForm(p.Save,h.Input(h.Name("name"),p.Name.Bind()),h.Select(h.Name("country"),p.Country.Bind(),on.Change(p.CountryChanged),h.Option(h.Value("pt"),h.Str("Portugal")),h.Option(h.Value("br"),h.Str("Brasil"))),h.Select(h.Name("region"),p.Region.Bind(),via.Each(regions[p.Country.Get()],option)),h.Small(h.Str(p.err)),h.Button(h.Type("submit"),h.Str("Save")),)}
A form can keep Signals on its inputs, so an on.Change reshapes it before the submit: here the country picks the region list. The submit is native: it posts fields by name, not the signal store, so in the handler via.Signal.Get returns the initial value. Give each bound input an h.Name, read it with FormValue, and via.Signal.Set the Signal from it. The answer renders those values, with the error beside them.
PostForm always sends multipart/form-data, so ctx.Request().FormFile works in the handler. This one hashes the bytes, sniffs the type from the first 512, and keeps nothing.
Try this
Upload a PNG renamed to .txt: the sniffed type ignores the name and the claimed type.
Submit with no file chosen.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("crypto/sha256""encoding/hex""io""net/http""strconv""github.com/go-via/via""github.com/go-via/via/h")// Upload reads a file from a native multipart submit and keeps nothing: it// hashes the bytes, sniffs the type and reports both. The results are plain// fields for the same reason as Signup's: the submit is a navigation, and they// only have to reach the page this request renders.typeUploadstruct{// Limiter: see shared_contract.go.LimLimiterreport[]stringerrstring}funcNewUpload(limLimiter)Upload{iflim==nil{panic("demos: NewUpload: Upload.Lim must not be nil")}returnUpload{Lim:lim}}func(u*Upload)Inspect(ctx*via.Ctx){if!u.Lim.Allow(ctx){u.err="Slow down: this demo takes 10 uploads a minute."return}f,hdr,err:=ctx.Request().FormFile("file")iferr!=nil{u.err="Pick a file first."return}deferf.Close()head:=make([]byte,512)n,_:=io.ReadFull(f,head)sum:=sha256.New()sum.Write(head[:n])size,err:=io.Copy(sum,f)iferr!=nil{u.err="The upload broke off."return}u.report=[]string{"name (client-supplied): "+hdr.Filename,"size: "+strconv.FormatInt(size+int64(n),10)+" bytes","sniffed type: "+http.DetectContentType(head[:n]),"claimed type: "+hdr.Header.Get("Content-Type"),"sha256: "+hex.EncodeToString(sum.Sum(nil))[:16]+"…",}}func(u*Upload)View()h.H{rows:=make([]h.H,0,len(u.report))for_,r:=rangeu.report{rows=append(rows,h.Li(h.Str(r)))}returnvia.PostForm(u.Inspect,h.Div(h.Class("row"),h.Input(h.Type("file"),h.Name("file")),h.Button(h.Type("submit"),h.Str("Inspect")),),h.Small(h.Class("err"),h.Str(u.err)),h.Ul(rows...),)}
A handler that keeps the file names it and checks it itself:
upload.go
func(a*Avatar)Save(ctx*via.Ctx){f,_,err:=ctx.Request().FormFile("avatar")iferr!=nil{a.msg="Pick a file."return}deferf.Close()head:=make([]byte,512)n,_:=io.ReadFull(f,head)ifhttp.DetectContentType(head[:n])!="image/png"{a.msg="PNG only."return}// The name is yours, never the client's header.Filename.dst,err:=os.CreateTemp("/var/uploads","avatar-*.png")iferr!=nil{a.msg="Could not save."return}deferdst.Close()if_,err:=f.Seek(0,io.SeekStart);err!=nil{a.msg="Could not save."return}if_,err:=io.Copy(dst,f);err!=nil{a.msg="Could not save."return}ctx.Redirect("/profile")}func(a*Avatar)View()h.H{returnvia.PostForm(a.Save,h.Input(h.Type("file"),h.Name("avatar"),h.Accept("image/png")),h.Button(h.Type("submit"),h.Str("Upload")),h.P(h.Str(a.msg)),)}
upload.go
funcnewRouter()*via.Router{returnvia.NewRouter(via.WithMaxUpload(20<<20),// whole body; over it is 413via.WithMaxBody(1<<20),// held in RAM; the rest spills to disk)}
via.WithMaxUpload caps the whole multipart body (default 8 MiB), and a larger one answers 413 before the handler runs. via.WithMaxBody caps how much of it is held in memory (default 1 MiB); via spills the rest to temp files and removes them after the handler returns.
on.Input fires on every keystroke. on.WithDebounce(300*time.Millisecond) holds the POST until typing stops for 300 ms. The box is a bound Signal, so the handler reads the latest text whatever the timing.
Type part of a standard library package name.
Try this
Type sync quickly and count the POSTs in the Wire pane: one, not four.
Type, pause, type again: one POST per pause.
Wire
Use the demo to see its requests and patches here.
Source
packagedemosimport("strings""time""github.com/go-via/via""github.com/go-via/via/h""github.com/go-via/via/on")varstdlib=[]string{"bufio","bytes","cmp","context","crypto/rand","crypto/sha256","database/sql","embed","encoding/base64","encoding/csv","encoding/json","errors","flag","fmt","hash/maphash","html/template","io","io/fs","iter","log","log/slog","maps","math","math/big","math/rand/v2","mime/multipart","net","net/http","net/http/httptest","net/netip","net/url","os","os/exec","os/signal","path/filepath","reflect","regexp","runtime","slices","sort","strconv","strings","sync","sync/atomic","testing","text/template","time","unicode/utf8","unique","unsafe",}// Search filters on the server as you type. The input is a bound Signal, so// every keystroke updates the signal in the browser; on.WithDebounce holds the// POST until typing pauses, and the handler sees the latest value.typeSearchstruct{Queryvia.Signal[string]hits[]stringaskedbool}func(s*Search)Run(ctx*via.Ctx){q:=strings.ToLower(strings.TrimSpace(s.Query.Get()))s.asked,s.hits=q!="",nilfor_,p:=rangestdlib{ifq!=""&&strings.Contains(p,q){s.hits=append(s.hits,p)}}}func(s*Search)View()h.H{status:="Type part of a standard library package name."ifs.asked&&len(s.hits)==0{status="No match."}rows:=make([]h.H,0,len(s.hits))for_,p:=ranges.hits{rows=append(rows,h.Li(h.Code(h.Str(p))))}returnh.Div(h.Input(s.Query.Bind(),on.Input(s.Run,on.WithDebounce(300*time.Millisecond)),h.Type("search"),h.Placeholder("http, sync, …"),h.AutoComplete("off")),h.P(h.Class("notice"),h.Str(status)),h.Ul(rows...),)}
Any other Datastar modifier, such as delay.300ms or passive. A modifier a typed option covers panics.
__delay.300ms
The options take the same form on the …CS twins. A duration of zero or less panics, and so do two different durations for one option on the same binding.
On a plain unit every action POST gets its own copy of the composition and runs on its own net/http goroutine. Two POSTs from one tab never share memory, but they can run at the same time and finish in either order.
On a live unit, one that renders a State or List or registers a Tick or Listen, every action, Tick and Listen handler for the tab runs on that tab's stream goroutine, one at a time, and the POST waits for its turn.
Neither protects what you share outside the composition. A package-level map, a store, or a tally every visitor sees needs its own lock, as Vote's does. In the browser, Datastar aborts an in-flight request when the same binding fires again, so a double click reads only the second answer; the first may still have run on the server.
The ?a= argument does not decode into the handler's type, or the body is malformed. A JSON body without Datastar-Request: true is read as a form, and the 400 names the header.
403
The Origin is not trusted (see via.WithTrustedOrigin), the tab's stream is bound to another session, or OnInit or OnReload returned via.ErrForbidden.
404
A path segment does not decode for ctx.Param, or OnReload returned via.ErrNotFound.
410
The render does not bind this action, or not with this argument, or the tab's stream is gone.
413
The body is over WithMaxBody, or a native submit is over WithMaxUpload.
500
The handler panicked, or OnReload returned any other error. The panic is logged with its stack, and the process keeps serving.
503
The session store did not answer, a live tab's goroutine did not pick the action up in time, too many actions are already waiting for their streams, or the router is shutting down.
An action posted by Datastar leaves the page as it was. On a page with a live unit, a 410 shows "Page is out of date" and reloads. Any other failure (a 500 from a handler panic, a 503, a 403) shows nothing. Listen for the datastar-fetch event of type error to show your own notice. A PostForm submit navigates to the error response: plain text, or the document via.WithErrorPage renders.