State Management
rfw v2 provides two reactive state primitives: signals for local component state and stores for shared global state. Both are auto-wired by composition.New based on field types — no tags required.
Signals
Signals are fine-grained reactive values. When a signal changes, only the template bindings and effects that read it re-render.
Creation
Use the t package constructors:
import t "github.com/rfwlab/rfw/v2/types"
count := t.NewInt(0)
name := t.NewString("")
done := t.NewBool(false)
price := t.NewFloat(9.99)
data := t.NewAny(nil)
Read with .Get(), write with .Set():
count.Set(42)
fmt.Println(count.Get()) // 42
Under the hood, t.Int is *state.Signal[int], t.String is *state.Signal[string], etc.
Signals are nil-safe: calling .Get() or .Set() on a nil *Signal[T] is a no-op (returns zero value for Get).
Auto-Wiring by Type
Declare signal fields on your struct using value types or pointer types:
type Counter struct {
composition.Component
Count t.Int // value type
Name *t.String // pointer type (auto-initialized if nil)
}
func (c *Counter) Inc() { c.Count.Set(c.Count.Get() + 1) }
func (c *Counter) Dec() { c.Count.Set(c.Count.Get() - 1) }
composition.New detects signal-type fields and:
- If the field is a value type (
t.Int), registers it as a prop viafield.Addr().
- If the field is a pointer type and nil (
*t.Int), auto-creates a zero-value signal and sets the field.
- If the field is a pointer type and non-nil, registers it directly as a prop.
Initialize with values:
view, err := composition.New(&Counter{
Count: *t.NewInt(5),
})
Or rely on auto-zero and set in OnMount:
type Counter struct {
composition.Component
Count *t.Int
}
func (c *Counter) OnMount() {
c.Count.Set(1)
}
Template Binding
Read-only, displays current value, updates DOM on change:
<span>@signal:Count</span>
<p>Name: @signal:Name</p>
Two-way, appends :w to write back from form controls:
<input value="@signal:Name:w">
<input type="checkbox" checked="@signal:Done:w">
<textarea>@signal:Bio:w</textarea>
Input changes write to the signal, and all other @signal:Name bindings update automatically.
Stores
Stores are namespaced key-value maps shared across components. They support modules, watchers, computed values, history (undo/redo), and persistence.
Creation
import "github.com/rfwlab/rfw/v2/state"
s := state.NewStore("cart", state.WithModule("app"))
s.Set("count", 0)
s.Set("total", 0.0)
Options:
| Option | Purpose |
|---|---|
state.WithModule("app") |
Namespace under a module |
state.WithHistory(10) |
Enable undo/redo with limit |
state.WithPersistence() |
Persist to localStorage |
state.WithDevTools() |
Log mutations (debug only) |
Auto-Wiring with *t.Store
type CartPage struct {
composition.Component
Cart *t.Store
}
composition.New detects *t.Store fields and calls comp.Store("Cart"), creating or retrieving the store scoped to the component.
Template Binding
Stores are referenced by fully-qualified path: @store:module.store.key
<p>Items: @store:app.cart.count</p>
<input value="@store:app.cart.name:w">
- Module defaults to
app, so@store:app.default.countreferences the default store.
- Append
:wfor two-way binding.
Computed Values
@expr: Inline Computed
Use @expr: in templates for inline derived values that re-evaluate when dependencies change:
<p>Double: @expr:Count.Get * 2</p>
<p>Greeting: @expr:Name.Get + " world"</p>
Supports arithmetic (+, -, *, /), comparisons (==, !=, <, >, <=, >=), logical operators (&&, ||, !), and field access (.Get).
Store Computed Values
Define computed values on stores that re-evaluate when dependencies change:
state.Map(s, "double", "count", func(v int) int { return v * 2 })
state.Map2(s, "fullName", "first", "last", func(first, last string) string {
return first + " " + last
})
Or custom computed values:
s.RegisterComputed(state.NewComputed(
"profile",
[]string{"first", "last", "age"},
func(m map[string]any) any {
return fmt.Sprintf("%s %s (%d)", m["first"], m["last"], m["age"])
},
))
Template reference:
<p>Full name: @store:app.default.fullName</p>
Watchers
React to store changes with watchers:
s.Watch("age", func(v any) {
log.Println("age updated", v)
})
Or via RegisterWatcher for multi-key observation:
remove := s.RegisterWatcher(state.NewWatcher(
[]string{"first", "last"},
func(m map[string]any) {
log.Println(m["first"], m["last"])
},
state.WatcherImmediate(),
))
// later: remove()
WatcherDeep(), match nested path changes
WatcherImmediate(), trigger on registration
Undo / Redo with *t.History
Use *t.History fields for undo/redo on stores:
type Editor struct {
composition.Component
Doc *t.Store
Hist *t.History
}
composition.New discovers *t.History fields and binds them to the component’s first store automatically.
Call methods in event handlers:
func (e *Editor) Save() { e.Hist.Snapshot() }
func (e *Editor) Undo() { e.Hist.Undo() }
func (e *Editor) Redo() { e.Hist.Redo() }
Template:
<button @on:click:Undo>Undo</button>
<button @on:click:Redo>Redo</button>
You can also create a history manually:
hist := t.NewHistory(50) // max 50 snapshots
hist.Bind(myStore)
hist.Snapshot()
hist.Undo()
hist.Redo()
Store Managers
By default, all stores are registered in the GlobalStoreManager. Create isolated managers for testing or sandboxing:
sm := state.NewStoreManager()
s := sm.NewStore("test", state.WithModule("app"))
Actions
Bundle mutations into reusable functions:
s := state.NewStore("counter")
s.Set("count", 0)
increment := state.Action(func(ctx state.Context) error {
current, _ := s.Get("count").(int)
s.Set("count", current+1)
return nil
})
_ = state.Dispatch(context.Background(), increment)
Persistence
Enable localStorage persistence:
s := state.NewStore("profile", state.WithModule("app"), state.WithPersistence())
s.Set("name", "Ada")
Values survive browser reloads.
DI Injection with *t.Inject[T]
Auto-fill struct fields from the DI container:
composition.Container().Provide("logger", &MyLogger{})
type Page struct {
composition.Component
Logger *t.Inject[Logger]
}
composition.New resolves the field from Container().Get("logger") and sets the inner Value. Works for any type — services, configs, API clients.
Effects
state.Effect tracks dependencies and re-runs when signals change. Used internally by the framework for template bindings. Prefer lifecycle hooks (OnMount/OnUnmount) or watchers in application code:
stop := state.Effect(func() func() {
fmt.Println("count is", count.Get())
return nil
})
// later: stop()
Type Reference
Signal Constructors
| Constructor | Type | Zero value |
|---|---|---|
t.NewInt(v) |
*t.Int |
0 |
t.NewString(v) |
*t.String |
"" |
t.NewBool(v) |
*t.Bool |
false |
t.NewFloat(v) |
*t.Float |
0.0 |
t.NewAny(v) |
*t.Any |
nil |
All support .Get() and .Set(). Nil-safe: calling .Get() on a nil pointer returns the zero value.
Host Signal Types
| Type | Underlying | Use |
|---|---|---|
t.HInt |
*Signal[int] |
SSC host-synced integer |
t.HString |
*Signal[string] |
SSC host-synced string |
t.HBool |
*Signal[bool] |
SSC host-synced boolean |
t.HFloat |
*Signal[float64] |
SSC host-synced float |
Special Types
| Type | Use |
|---|---|
*t.Store |
Component-scoped store |
*t.Ref |
Template DOM ref |
*t.Inject[T] |
DI injection |
*t.History |
Undo/redo bound to a store |
*t.View |
Child view (include slot) |
*t.Slice[T] |
Reactive slice signal |
*t.Map[K,V] |
Reactive map signal |
t.Prop[T] |
Reactive prop |
Store Options
| Option | Effect |
|---|---|
state.WithModule(m) |
Namespace under module m |
state.WithHistory(n) |
Enable undo/redo, max n entries |
state.WithPersistence() |
Persist to localStorage |
state.WithDevTools() |
Log all mutations |
Template Directive Summary
| Directive | Purpose |
|---|---|
@signal:Name |
Read signal value |
@signal:Name:w |
Two-way signal binding |
@store:m.s.k |
Read store key |
@store:m.s.k:w |
Two-way store binding |
@expr: |
Inline computed expression |
@on:event:handler |
DOM event → Go method |