via

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

Signals

A signal is a Datastar value that lives in the browser, declared by a field of your composition. Bind it to an input, display it, compute over it with expr.Expr expressions: none of that sends a request. The server writes one with a Set when it has something to say.

On this page

#Where state lives

The field's type decides where a value lives and who sees it.

HandleLives onScope
via.Signalbrowser, sent with each actionper tab
via.SignalCSbrowser only, never sentper tab
via.Stateserverper tab, pushed when it changes
via.Listserverper tab; a State of a slice, with Append, Remove and Each
via.StateTrackserverper tab, following one store every tab shares
topic.Topicserverper process; fan-out to every Ctx.Listen on it

Signal and SignalCS are covered below. The server-side handles are on Live state.

#Wire names

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

type Inbox struct {
	Count via.Signal[int]    // $count
	Open  via.SignalCS[bool] // $_open
	Form  struct {
		Email via.Signal[string] // $form_email
	}
	Chat Chat // rendered with via.Child(p.Chat)
}

A signal's wire name is its Go field name with the first letter lower-cased. A plain nested struct joins with one underscore, a child rendered with via.Child prefixes its field name and a double underscore, and a via.SignalCS name starts with an underscore. via.Signal.Ref returns the name as an expression, so you never spell $chat__draft by hand; renaming the field renames the signal.

#Binding and display

#Greeting

via.Signal.Bind and via.Signal.Display are two views of one field, so they share its wire name whatever order they render in. Typing reaches no handler: the browser updates the text on its own.

Hello, !

The greeting appears once you type.

Try this

  • Type a name. The greeting follows as you type.
  • Watch the Wire pane: the signal changes with each keystroke and no request appears.
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"
)

// Greeting is two views of one signal: Bind() and Display() resolve to the
// same wire name, so the sentence follows the input with no request at all.
type Greeting struct{ Name via.Signal[string] }

func (g *Greeting) View() h.H {
	return h.Div(
		h.Label(h.Str("Your name"),
			h.Input(g.Name.Bind(), h.Placeholder("type here"), h.AutoComplete("off")),
		),
		h.P(h.DataShow(g.Name.Ref().Ne("")), h.Str("Hello, "), g.Name.Display(), h.Str("!")),
		h.P(h.DataShow(g.Name.Ref().Eq("")), h.Small(h.Str("The greeting appears once you type."))),
	)
}

A via:"init=…" tag gives the start value as JSON, so a string is quoted. The source, then the HTML it renders:

greeting.go
type Greeting struct {
	Name via.Signal[string] `via:"init=\"Ada\""`
}

func (g *Greeting) View() h.H {
	return h.Div(
		h.Input(g.Name.Bind()),
		h.P(h.Str("Hello, "), g.Name.Display()),
	)
}
rendered HTML
<div id="root" data-signals='{"name":"Ada"}'>
  <div>
    <input data-bind="name">
    <p>Hello, <span data-text="$name">Ada</span></p>
  </div>
</div>

data-signals on the root declares the value, data-bind makes the input write it, and data-text shows it. The span carries the value as text too, so the first paint reads right before Datastar loads. When an action runs, the browser posts its signals back, and via.Signal.Get returns the posted value of a Bound one.

#Client-only signals

#Toggle

via.SignalCS is for UI state the server has no use for: an open panel, the active tab. Its name starts with an underscore, which Datastar's fetch filter drops, so it never reaches a POST. It has no Get and no Set; the tag is the only way to give it a start value.

Shown by data-show, not by a re-render. The server has no field for this and no handler ran.

Try this

  • Click the button to show and hide the panel.
  • Open the Wire pane: _toggle__open flips and no request appears. The flag and the handler both live in the browser.
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"
)

// Toggle holds a panel open or closed with a SignalCS, whose wire name is
// _-prefixed — Datastar's fetch filter drops those, so the flag never leaves
// the browser. The tag decides what it starts as at first paint.
type Toggle struct {
	Open via.SignalCS[bool] `via:"init=false"`
}

func (t *Toggle) View() h.H {
	return h.Div(
		h.Button(on.ClickCS(t.Open.Ref().Toggle()), h.Str("open: "), t.Open.Display()),
		h.Div(h.Class("panel"), h.DataShow(t.Open.Ref()),
			h.P(h.Str("Shown by data-show, not by a re-render. The server has no field "+
				"for this and no handler ran.")),
		),
	)
}

#Server-set signals

#Server seed

via.Signal.Set writes the value and declares the signal, so the View need not render it. A Set in OnInit puts the server clock in the first paint, and the action sets it again. The action's response carries only the signals it wrote, so an input the user is editing is not overwritten.

Try this

  • Click read the clock and open the Wire pane: one POST, and a patch carrying the new signal value.
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"
)

// Seed never binds or displays its signal: Set is what declares one, so the
// server clock below reaches the browser on first paint from OnInit, and the
// action re-Sets the same wire name. No element here holds the time as text —
// it rides on the region's signals and data-text reads it.
type Seed struct{ Clock via.Signal[string] }

func (s *Seed) OnInit(ctx *via.Ctx) error {
	s.Clock.Set(serverTime())
	return nil
}

func (s *Seed) Refresh(ctx *via.Ctx) { s.Clock.Set(serverTime()) }

func (s *Seed) View() h.H {
	return h.Div(h.Class("row"),
		h.Button(on.Click(s.Refresh), h.Str("read the clock")),
		h.Output(h.DataText(s.Clock.Ref())),
	)
}

func serverTime() string { return time.Now().UTC().Format("15:04:05") + " UTC" }

A JavaScript island gets data from Go the same way: Set a signal the island reads, and no element has to spell the value out. Declaring is not binding: the server ignores a posted value for a Set signal unless something Binds it.

#Expressions

#Computed

Ref hands a signal to the expr package, and the h.Data* attributes take the expression it builds. The sum and the class on it derive from the same two refs, and the browser re-evaluates both on every keystroke.

A + B = (turns amber above 10)

Try this

  • Raise A or B until the sum passes 10; it turns amber.
  • Open the Wire pane: the signals change and no request appears. A Bound signal is sent only when an action runs.
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/expr"
	"github.com/go-via/via/h"
)

// Computed derives from two signals in the browser. The sum and the class on
// it are expressions over the same two refs, re-evaluated on every keystroke.
type Computed struct {
	A via.Signal[int] `via:"init=2"`
	B via.Signal[int] `via:"init=3"`
}

// expr has no arithmetic operators (Add is the in-place +=), so the sum is
// Rawf text with the two refs spliced in checked rather than spelled by hand.
func (c *Computed) sum() expr.Expr {
	return expr.Rawf("(%s + %s)", c.A.Ref(), c.B.Ref())
}

func (c *Computed) View() h.H {
	sum := c.sum()
	return h.Div(
		h.Label(h.Str("A"), h.Input(h.Type("number"), c.A.Bind())),
		h.Label(h.Str("B"), h.Input(h.Type("number"), c.B.Bind())),
		h.P(h.Str("A + B = "),
			h.Span(h.Class("total"), h.DataText(sum), h.DataClass("over", sum.Gt(10))),
			h.Small(h.Str(" (turns amber above 10)")),
		),
	)
}

An expr.Expr goes into any h.DataShow-style attribute or an on.ClickCS-style handler. The right-hand column is what each helper produces, rendered from these calls:

HelperProducesNotes
Ref$countthe signal; a SignalCS ref starts with $_
expr.Val"\u0040post("any JSON-encodable Go value as a literal, @ escaped
Eq Ne Lt Le Gt Ge($count > 10)Eq and Ne are strict === and !==
Not(!$_open)
Assign$name = ""receiver must be a bare signal ref
Add$count += 1in place; there is no a + b
Toggle$_open = !$_openin place
expr.All expr.Any(($count > 0) && $_open)&& and ||, operands grouped
expr.Do$count += 1; $_open = !$_openstatements in sequence
expr.Callconsole.log($count)a function by dotted name
expr.Classel.classList.toggle("copied", true)on the element the attribute sits on
expr.CopyToClipboardnavigator.clipboard.writeText($name).catch(() => {})secure context and a click only
expr.CopyTextOfnavigator.clipboard.writeText(el.parentElement?.querySelector("pre")?.textContent ?? "").catch(() => {})first match inside the element's parent
expr.Raw expr.Rawf($a + $b)text unchecked, %s args spliced as-is

#Copy to clipboard

expr.CopyTextOf copies the text of an element next to the button, read from the page at click time. expr.Class flags the button for a moment: feedback that needs no signal. The clipboard works only in a secure context and from a click. A refused write is swallowed, so it fails with nothing in the console.

go get github.com/go-via/via

Try this

  • Click Copy, then paste somewhere.
  • Open the Wire pane: no request appears. Copying never involves the server.
Wire

Use the demo to see its requests and patches here.

Source
package demos

import (
	"time"

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

// SignalsCopy copies the command beside its button and flags the button for a
// moment. The class is feedback not worth a signal, so it has none, and the
// text is read off the DOM instead of repeated in the attribute.
type SignalsCopy struct{}

func (c *SignalsCopy) View() h.H {
	return h.Div(h.Class("code"),
		h.Pre(h.Code(h.Str("go get github.com/go-via/via"))),
		h.Button(h.Class("copy"), h.Type("button"),
			on.ClickCS(expr.Do(expr.CopyTextOf("pre"), expr.Class("copied", true))),
			on.ClickCS(expr.Class("copied", false), on.WithDebounce(1500*time.Millisecond)),
			h.Span(h.Class("icon-clip"), h.Str("Copy")),
			h.Span(h.Class("copy-done"), h.Aria("live", "polite"), h.Str("Copied")),
		),
	)
}