10 KiB
State and Interactivity
This is the core reactive model — signals, actions, views, components, and validation.
Context Lifecycle
A *Context is created per browser visit. It holds all page state: signals, actions, fields, subscriptions, and the view function.
Browser hits page → new Context created → init function runs → HTML rendered
↓
SSE connection opens ← browser loads page
↓
action fires → signals injected from browser → handler runs → Sync() → DOM patched
The context lives until the browser tab closes (detected via a beforeunload beacon) or the server shuts down. There is no background reaper — contexts persist across temporary SSE disconnections so backgrounded tabs resume seamlessly.
During SPA navigation, the context itself survives — only page-level state (signals, actions, fields, intervals, subscriptions) is reset. The SSE connection persists.
Signals
Signals are reactive values synchronized between server and browser. Create one with an initial value:
name := c.Signal("world")
count := c.Signal(0)
items := c.Signal([]string{"a", "b"})
Reading values
name.String() // "world"
count.Int() // 0
count.Bool() // false (parses "true", "1", "yes", "on")
Signal values come from the browser. Before every action call, the browser sends all current signal values to the server. You always read the latest browser state inside action handlers.
Writing values
name.SetValue("Via")
c.SyncSignals() // push only changed signals to browser
// or
c.Sync() // re-render view AND push changed signals
SetValue marks the signal as changed. The change is not sent to the browser until you call Sync() or SyncSignals().
Rendering in the view
// Two-way binding on an input — browser edits update the signal
h.Input(h.Type("text"), name.Bind())
// Reactive text display — updates when the signal changes
h.Span(name.Text())
// Read value at render time — static until next Sync()
h.P(h.Textf("Count: %d", count.Int()))
Bind() outputs a data-bind attribute for two-way binding. Text() outputs a <span data-text="$signalID"> for reactive display.
Actions
Actions are server-side event handlers. They run on the server when triggered by a browser event.
submit := c.Action(func() {
// signals are already injected — read them here
fmt.Println(name.String())
count.SetValue(count.Int() + 1)
c.Sync()
})
Trigger methods
Attach an action to a DOM event by calling a trigger method in the view:
h.Button(h.Text("Submit"), submit.OnClick())
h.Input(name.Bind(), submit.OnKeyDown("Enter"))
h.Select(category.Bind(), filter.OnChange())
h.Form(submit.OnSubmit())
Available triggers:
| Method | Event | Notes |
|---|---|---|
OnClick() |
click |
|
OnDblClick() |
dblclick |
|
OnChange() |
change |
200ms debounce |
OnInput() |
input |
No debounce |
OnSubmit() |
submit |
|
OnKeyDown(key) |
keydown |
Filtered by key name (e.g. "Enter", "Escape") |
OnFocus() |
focus |
|
OnBlur() |
blur |
|
OnMouseEnter() |
mouseenter |
|
OnMouseLeave() |
mouseleave |
|
OnScroll() |
scroll |
Trigger options
Every trigger method accepts ActionTriggerOption values:
// Set a signal value before the action fires
submit.OnClick(via.WithSignal(mode, "delete"))
submit.OnClick(via.WithSignalInt(page, 3))
// Listen on window instead of the element
submit.OnKeyDown("Escape", via.WithWindow())
// Prevent browser default behavior
submit.OnKeyDown("ArrowDown", via.WithPreventDefault())
Multi-key dispatch
OnKeyDownMap binds multiple keys to different actions in a single attribute:
via.OnKeyDownMap(
via.KeyBind("w", move, via.WithSignal(dir, "up")),
via.KeyBind("s", move, via.WithSignal(dir, "down")),
via.KeyBind("ArrowUp", move, via.WithSignal(dir, "up"), via.WithPreventDefault()),
via.KeyBind("ArrowDown", move, via.WithSignal(dir, "down"), via.WithPreventDefault()),
)
This produces a single data-on:keydown__window attribute. Place it on any element in the view.
Action options
// Per-action rate limiting (overrides the context-level default)
c.Action(handler, via.WithRateLimit(5, 10))
// Per-action middleware (runs after CSRF and rate-limit checks)
c.Action(handler, via.WithMiddleware(requireAdmin))
Views and Sync
Every page handler must call c.View() to define the UI:
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Hello, %s!", name.String())),
)
})
Gotcha: If you forget
c.View(), the app panics at startup during route registration — not at request time.
The view function is re-evaluated on every c.Sync(). The resulting HTML is pushed to the browser via SSE, where Datastar morphs the DOM.
Sync variants
| Method | What it sends |
|---|---|
c.Sync() |
Re-renders the view HTML and pushes changed signals |
c.SyncSignals() |
Pushes only changed signals, no view re-render |
c.SyncElements(elem...) |
Pushes specific HTML elements to merge into the DOM. Each element must have an ID matching an existing DOM element |
c.ExecScript(js) |
Sends JavaScript for the browser to execute (auto-removed after execution) |
Use SyncSignals() when only signal values changed and the view structure is the same. Use SyncElements() for targeted updates without re-rendering the entire view. Use ExecScript() to interact with client-side libraries (e.g. pushing data to a chart).
Components
Extract reusable UI with c.Component():
func counterFn(c *via.Context) {
count := 0
step := c.Signal(1)
increment := c.Action(func() {
count += step.Int()
c.Sync()
})
c.View(func() h.H {
return h.Div(
h.P(h.Textf("Count: %d", count)),
h.Input(h.Type("number"), step.Bind()),
h.Button(h.Text("+"), increment.OnClick()),
)
})
}
// In a page:
v.Page("/", func(c *via.Context) {
counter1 := c.Component(counterFn)
counter2 := c.Component(counterFn)
c.View(func() h.H {
return h.Div(
h.H2(h.Text("Counter 1")), counter1(),
h.H2(h.Text("Counter 2")), counter2(),
)
})
})
Each component instance gets its own closure state, but signals, actions, and fields are registered on the parent page context. Components share the parent's SSE stream — c.Sync() from a component re-renders the entire page view.
Fields and Validation
Fields are signals with validation rules. Use them for form inputs:
username := c.Field("", via.Required(), via.MinLen(3), via.MaxLen(20))
email := c.Field("", via.Required(), via.Email())
age := c.Field("", via.Required(), via.Min(13), via.Max(120))
website := c.Field("", via.Pattern(`^https?://`, "Must start with http:// or https://"))
Built-in rules
| Rule | Description |
|---|---|
Required(msg...) |
Rejects empty/whitespace-only values |
MinLen(n, msg...) |
Minimum character count (Unicode-aware) |
MaxLen(n, msg...) |
Maximum character count (Unicode-aware) |
Min(n, msg...) |
Minimum numeric value (parsed as int) |
Max(n, msg...) |
Maximum numeric value (parsed as int) |
Email(msg...) |
Email format regex |
Pattern(re, msg...) |
Custom regex |
Custom(fn) |
func(string) error — return non-nil to fail |
All rules accept an optional custom error message as the last argument.
Using fields in views and actions
submit := c.Action(func() {
if !c.ValidateAll() {
c.Sync()
return
}
// Server-side validation
if userExists(username.String()) {
username.AddError("Username taken")
c.Sync()
return
}
createUser(username.String(), email.String())
c.ResetFields()
c.Sync()
})
c.View(func() h.H {
return h.Form(submit.OnSubmit(),
h.Input(h.Type("text"), username.Bind(), h.Placeholder("Username")),
h.If(username.HasError(), h.Small(h.Text(username.FirstError()))),
h.Input(h.Type("email"), email.Bind(), h.Placeholder("Email")),
h.If(email.HasError(), h.Small(h.Text(email.FirstError()))),
h.Button(h.Type("submit"), h.Text("Sign Up")),
)
})
| Method | Description |
|---|---|
field.Validate() |
Run rules, return true if all pass |
field.HasError() |
True if any validation errors exist |
field.FirstError() |
First error message, or "" |
field.Errors() |
All error messages |
field.AddError(msg) |
Add a custom server-side error |
field.ClearErrors() |
Remove all errors |
field.Reset() |
Restore initial value and clear errors |
c.ValidateAll(fields...) |
Validate given fields (or all if none specified). Does not short-circuit — all fields get validated so all errors are populated |
c.ResetFields(fields...) |
Reset given fields (or all if none specified) |
Fields embed *signal, so Bind(), Text(), String(), Int(), Bool(), SetValue(), and ID() all work.
OnInterval
Run a function at regular intervals, tied to the page lifecycle:
stop := c.OnInterval(time.Second, func() {
now = time.Now()
c.Sync()
})
- Starts immediately — no separate start call needed.
- Returns a
func()that stops the interval (idempotent). - Automatically stops on context disposal (tab close) or SPA navigation away.
- Call
c.Sync()inside the handler to push updates to the browser.
Navigation Helpers
| Method | Effect |
|---|---|
c.Redirect(url) |
Full page navigation. Disposes the context, browser loads a new page |
c.Redirectf(fmt, args...) |
Redirect with fmt.Sprintf |
c.RedirectView(url) |
Sets the view to trigger a redirect on SSE connect. Use in middleware to abort the chain and redirect |
c.ReplaceURL(url) |
Updates the browser URL bar without navigation. Useful for reflecting state in query params |
c.ReplaceURLf(fmt, args...) |
ReplaceURL with fmt.Sprintf |
c.Navigate(path, popstate) |
SPA navigation. Resets page state, runs the target page handler on the same context, pushes the new view with a view transition |
Gotcha: In middleware, use
c.RedirectView(), notc.Redirect().Redirectsends a patch over SSE, but the SSE connection isn't established yet during the initial page load.