314 lines
10 KiB
Markdown
314 lines
10 KiB
Markdown
# 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 is disposed when the SSE connection closes (tab close, navigation away, network loss). A background reaper also cleans up contexts that never establish an SSE connection within `ContextTTL` (default 30s).
|
|
|
|
During [SPA navigation](routing-and-navigation.md#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:
|
|
|
|
```go
|
|
name := c.Signal("world")
|
|
count := c.Signal(0)
|
|
items := c.Signal([]string{"a", "b"})
|
|
```
|
|
|
|
### Reading values
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
// 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.
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
// 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:
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
// 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:
|
|
|
|
```go
|
|
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()`:
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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](routing-and-navigation.md#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](routing-and-navigation.md#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()`, not `c.Redirect()`. `Redirect` sends a patch over SSE, but the SSE connection isn't established yet during the initial page load.
|