{
return &ReactiveVar[T]{
value: initial,
}
}
ReactiveString is a convenience alias for ReactiveVar[string].
ReactiveVar[string]
Computed represents a derived state value based on other store keys.
It holds the target key for the computed value, the list of dependencies
and the function used to calculate the value.
Key returns the store key associated with the computed value.
{ return c.key }
Deps returns the list of keys this computed value depends on.
{ return c.deps }
Evaluate executes the compute function using the provided state and returns
the result.
{
return c.compute(state)
}
NewComputed creates a new Computed value.
{
return &Computed{key: key, deps: deps, compute: compute}
}
Watcher represents a callback that reacts to changes on specific store keys.
When any of the dependencies change, the associated function is triggered.
Deps returns the list of keys the watcher observes.
{ return w.deps }
Run triggers the watcher with the provided state.
{ w.run(state) }
WatcherOption configures optional watcher behaviour.
func(*Watcher)
WatcherDeep enables deep watching of nested keys.
{ return func(w *Watcher) { w.deep = true } }
WatcherImmediate triggers the watcher immediately after registration.
{ return func(w *Watcher) { w.immediate = true } }
NewWatcher creates a new Watcher.
{
w := &Watcher{deps: deps, run: run}
for _, opt := range opts {
opt(w)
}
return w
}
{
var gotID int
var gotVal any
SignalHook = func(id int, v any) {
gotID = id
gotVal = v
}
s := NewSignal(1)
s.Set(2)
if gotID != s.id || gotVal != 2 {
t.Fatalf("hook not invoked")
}
snap := SnapshotSignals()
if v, ok := snap[s.id]; !ok || v != 2 {
t.Fatalf("snapshot missing or incorrect, got %v", snap)
}
}
Context is an alias of context.Context used by Actions.
This allows the API to remain stable if a custom context is needed later.
context.Context
Action represents a unit of work executed with a Context.
It returns an error if the action fails.
func(ctx Context) error
Dispatch executes the given Action with the provided context.
If the action is nil it is a no-op and nil is returned.
{
if a == nil {
return nil
}
return a(ctx)
}
UseAction binds an Action to a Context and returns a function
that executes the action when invoked. It can be used in places
that expect a simple callback.
{
return func() error {
return Dispatch(ctx, a)
}
}
{
s := NewStore("test")
s.Set("a", 1)
evalCount := 0
c := NewComputed("double", []string{"a"}, func(m map[string]any) any {
evalCount++
return m["a"].(int) * 2
})
s.RegisterComputed(c)
if evalCount != 1 {
t.Fatalf("expected 1 evaluation, got %d", evalCount)
}
if v := s.Get("double"); v != 2 {
t.Fatalf("expected computed value 2, got %v", v)
}
// Setting dependency to same value should not re-evaluate
s.Set("a", 1)
if evalCount != 1 {
t.Fatalf("computed re-evaluated without dependency change")
}
// Setting unrelated key should not re-evaluate
s.Set("b", 5)
if evalCount != 1 {
t.Fatalf("computed re-evaluated for unrelated key")
}
// Changing dependency should trigger re-evaluation
s.Set("a", 3)
if evalCount != 2 {
t.Fatalf("expected second evaluation after dependency change, got %d", evalCount)
}
if v := s.Get("double"); v != 6 {
t.Fatalf("expected computed value 6, got %v", v)
}
}
{
s := NewStore("test")
s.Set("count", 2)
Map(s, "double", "count", func(v int) int { return v * 2 })
if v := s.Get("double"); v != 4 {
t.Fatalf("expected 4, got %v", v)
}
s.Set("count", 3)
if v := s.Get("double"); v != 6 {
t.Fatalf("expected 6 after update, got %v", v)
}
s.Set("first", "Ada")
s.Set("last", "Lovelace")
Map2(s, "fullName", "first", "last", func(f, l string) string {
return strings.TrimSpace(f + " " + l)
})
if v := s.Get("fullName"); v != "Ada Lovelace" {
t.Fatalf("expected full name Ada Lovelace, got %v", v)
}
s.Set("last", "Hopper")
if v := s.Get("fullName"); v != "Ada Hopper" {
t.Fatalf("expected full name Ada Hopper, got %v", v)
}
}
{
ExposeUpdateStore()
js.Call("goUpdateStore", "mod", "test", "flag", true)
store := GlobalStoreManager.GetStore("mod", "test")
if store == nil {
t.Fatalf("store not created")
}
v, ok := store.Get("flag").(bool)
if !ok || !v {
t.Fatalf("expected true bool, got %#v", store.Get("flag"))
}
}
loadPersistedState is a no-op on non-JS platforms.
{ return nil }
saveState is a no-op on non-JS platforms.
{}
{
cl.logs = append(cl.logs, fmt.Sprintf(format, args...))
}
{
// capture and restore global state
oldLogger := logger
cl := &capturingLogger{}
SetLogger(cl)
defer SetLogger(oldLogger)
oldGSM := GlobalStoreManager
GlobalStoreManager = &StoreManager{modules: make(map[string]map[string]*Store)}
defer func() { GlobalStoreManager = oldGSM }()
// store without devtools should not log
s1 := NewStore("s1")
s1.OnChange("a", func(any) {})
if len(cl.logs) != 0 {
t.Fatalf("expected no logs for store without devtools, got %v", cl.logs)
}
// store with devtools should log
cl.logs = nil
s2 := NewStore("s2", WithDevTools())
s2.OnChange("a", func(any) {})
if len(cl.logs) == 0 {
t.Fatalf("expected logs for store with devtools enabled")
}
}
{
s := NewStore("hist", WithHistory(10))
s.Set("count", 1)
s.Set("count", 2)
if v := s.Get("count"); v != 2 {
t.Fatalf("expected 2, got %v", v)
}
s.Undo()
if v := s.Get("count"); v != 1 {
t.Fatalf("expected 1 after undo, got %v", v)
}
s.Redo()
if v := s.Get("count"); v != 2 {
t.Fatalf("expected 2 after redo, got %v", v)
}
}
{
s := NewStore("limit", WithHistory(2))
s.Set("val", 1)
s.Set("val", 2)
s.Set("val", 3)
s.Set("val", 4)
// history limit 2 means only last two changes are tracked
s.Undo() // 4 -> 3
if v := s.Get("val"); v != 3 {
t.Fatalf("expected 3 after first undo, got %v", v)
}
s.Undo() // 3 -> 2
if v := s.Get("val"); v != 2 {
t.Fatalf("expected 2 after second undo, got %v", v)
}
s.Undo() // no effect, history exhausted
if v := s.Get("val"); v != 2 {
t.Fatalf("expected 2 after exhausting history, got %v", v)
}
}
{
s := NewStore("redo", WithHistory(10))
s.Set("a", 1)
s.Set("a", 2)
s.Undo() // a ->1, future has mutation 1->2
s.Set("a", 3) // new mutation should clear redo stack
s.Redo() // should do nothing
if v := s.Get("a"); v != 3 {
t.Fatalf("expected 3 after redo with cleared history, got %v", v)
}
}
ExposeUpdateStore exposes a JS function to update store values.
{
js.Set("goUpdateStore", js.FuncOf(func(this js.Value, args []js.Value) any {
if len(args) < 4 {
return nil
}
module := args[0].String()
storeName := args[1].String()
key := args[2].String()
var newValue any
switch args[3].Type() {
case js.TypeString:
newValue = args[3].String()
case js.TypeBoolean:
newValue = args[3].Bool()
case js.TypeNumber:
newValue = args[3].Float()
default:
newValue = args[3]
}
store := GlobalStoreManager.GetStore(module, storeName)
if store == nil {
store = NewStore(storeName, WithModule(module))
}
store.Set(key, newValue)
return nil
}))
}
loadPersistedState retrieves persisted state from localStorage.
{
ls := js.LocalStorage()
if !ls.Truthy() {
return nil
}
item := ls.Call("getItem", key)
if item.Type() != js.TypeString {
return nil
}
var state map[string]any
if err := json.Unmarshal([]byte(item.String()), &state); err != nil {
return nil
}
return state
}
saveState persists the store state in localStorage.
{
ls := js.LocalStorage()
if !ls.Truthy() {
return
}
data, err := json.Marshal(state)
if err != nil {
return
}
ls.Call("setItem", key, string(data))
}
{
sigMu.Lock()
id := nextSigID
nextSigID++
sigValues[id] = v
sigMu.Unlock()
return id
}
{
sigMu.Lock()
sigValues[id] = v
sigMu.Unlock()
if SignalHook != nil {
SignalHook(id, v)
}
}
SnapshotSignals returns a copy of all tracked signals.
{
sigMu.Lock()
defer sigMu.Unlock()
snap := make(map[int]any, len(sigValues))
for k, v := range sigValues {
snap[k] = v
}
return snap
}
{ return 0 }
{}
{ return nil }
{
called := false
a := Action(func(ctx Context) error {
called = true
return nil
})
if err := Dispatch(context.Background(), a); err != nil {
t.Fatalf("dispatch returned error: %v", err)
}
if !called {
t.Fatalf("action was not executed")
}
}
{
called := false
a := Action(func(ctx Context) error {
called = true
return nil
})
fn := UseAction(context.Background(), a)
if err := fn(); err != nil {
t.Fatalf("use action returned error: %v", err)
}
if !called {
t.Fatalf("action not executed via UseAction")
}
}
{
a := NewSignal(0)
b := NewSignal(0)
var runs int
stop := Effect(func() func() {
_ = a.Get()
runs++
return nil
})
defer stop()
b.Set(1)
if runs != 1 {
t.Fatalf("effect ran on unrelated signal change")
}
a.Set(1)
if runs != 2 {
t.Fatalf("effect did not rerun on dependent signal change")
}
}
{
s := NewSignal(0)
var cleans int
stop := Effect(func() func() {
_ = s.Get()
return func() { cleans++ }
})
s.Set(1)
if cleans != 1 {
t.Fatalf("cleanup not called before rerun, got %d", cleans)
}
stop()
if cleans != 2 {
t.Fatalf("cleanup not called on stop, got %d", cleans)
}
}
{ log.Printf(format, args...) }
{ logger = l }
StoreOption configures optional behaviour for a Store during creation.
func(*Store)
WithModule namespaces a store under the provided module.
{ return func(s *Store) { s.module = module } }
WithPersistence enables localStorage persistence for the store.
{ return func(s *Store) { s.persist = true } }
WithDevTools enables logging of state mutations for development.
{ return func(s *Store) { s.devTools = true } }
WithHistory enables mutation history with the provided limit.
The limit controls how many past mutations are retained for undo/redo.
{
return func(s *Store) {
if limit > 0 {
s.historyLimit = limit
}
}
}
Module reports the module namespace of the store.
{ return s.module }
Name returns the store name within its module namespace.
{ return s.name }
Snapshot copies the current state of the store.
{
snap := make(map[string]any, len(s.state))
for k, v := range s.state {
snap[k] = v
}
return snap
}
{ return s.module + ":" + s.name }
{
old := s.state[key]
s.state[key] = value
if s.historyLimit > 0 && !s.suppressHistory {
s.history = append(s.history, &mutation{key: key, previous: old, next: value})
if len(s.history) > s.historyLimit {
s.history = s.history[len(s.history)-s.historyLimit:]
}
s.future = nil
}
if s.devTools {
logger.Debug("%s/%s -> %s: %v", s.module, s.name, key, value)
}
if StoreHook != nil {
StoreHook(s.module, s.name, key, value)
}
if listeners, exists := s.listeners[key]; exists {
for _, listener := range listeners {
listener(value)
}
}
s.evaluateDependents(key)
if s.persist {
saveState(s.storageKey(), s.state)
}
}
{
if s.devTools {
logger.Debug("Getting %s from %s/%s", key, s.module, s.name)
}
return s.state[key]
}
Undo reverts the last mutation recorded in the store's history.
{
if len(s.history) == 0 {
return
}
m := s.history[len(s.history)-1]
s.history = s.history[:len(s.history)-1]
s.suppressHistory = true
s.Set(m.key, m.previous)
s.suppressHistory = false
s.future = append(s.future, m)
}
Redo reapplies the last mutation that was undone.
{
if len(s.future) == 0 {
return
}
m := s.future[len(s.future)-1]
s.future = s.future[:len(s.future)-1]
s.suppressHistory = true
s.Set(m.key, m.next)
s.suppressHistory = false
s.history = append(s.history, m)
if s.historyLimit > 0 && len(s.history) > s.historyLimit {
s.history = s.history[len(s.history)-s.historyLimit:]
}
}
{
if s.listeners[key] == nil {
s.listeners[key] = make(map[int]func(any))
}
s.listenerID++
id := s.listenerID
s.listeners[key][id] = listener
if s.devTools {
logger.Debug("------")
for moduleName, stores := range GlobalStoreManager.modules {
for storeName, store := range stores {
logger.Debug("Store: %s/%s", moduleName, storeName)
for key, value := range store.state {
logger.Debug(" %s: %v", key, value)
}
}
}
logger.Debug("------")
}
return func() {
delete(s.listeners[key], id)
}
}
RegisterComputed registers a computed value on the store. The computed value
is evaluated immediately and whenever one of its dependencies changes.
{
s.computeds[c.Key()] = c
val := c.Evaluate(s.state)
s.state[c.Key()] = val
c.lastDeps = snapshotDeps(s.state, c.Deps())
}
RegisterWatcher registers a watcher that triggers when any of its
dependencies change. If the dependency list is empty the watcher is triggered
on every state update. It returns a function that removes the watcher.
{
s.watchers = append(s.watchers, w)
if w.immediate {
w.Run(s.state)
}
return func() {
for i, watcher := range s.watchers {
if watcher == w {
s.watchers = append(s.watchers[:i], s.watchers[i+1:]...)
break
}
}
}
}
evaluateDependents re-evaluates computed values and triggers watchers for a
given key.
{
for _, c := range s.computeds {
if contains(c.Deps(), key) {
current := snapshotDeps(s.state, c.Deps())
if c.lastDeps == nil || !reflect.DeepEqual(current, c.lastDeps) {
val := c.Evaluate(s.state)
s.state[c.Key()] = val
c.lastDeps = current
if listeners, exists := s.listeners[c.Key()]; exists {
for _, listener := range listeners {
listener(val)
}
}
// propagate to computeds/watchers depending on this key
s.evaluateDependents(c.Key())
}
}
}
for _, w := range s.watchers {
deps := w.Deps()
if len(deps) == 0 {
w.Run(s.state)
continue
}
for _, dep := range deps {
if w.deep {
if pathMatches(key, dep) {
w.Run(s.state)
break
}
} else {
if key == dep {
w.Run(s.state)
break
}
}
}
}
}
{
store := &Store{
module: "default",
name: name,
state: make(map[string]any),
listeners: make(map[string]map[int]func(any)),
computeds: make(map[string]*Computed),
}
for _, opt := range opts {
opt(store)
}
sm.RegisterStore(store.module, name, store)
if store.persist {
if state := loadPersistedState(store.storageKey()); state != nil {
store.state = state
}
}
return store
}
{
sm.mu.Lock()
defer sm.mu.Unlock()
if sm.modules[module] == nil {
sm.modules[module] = make(map[string]*Store)
}
sm.modules[module][name] = store
}
{
sm.mu.RLock()
defer sm.mu.RUnlock()
if stores, ok := sm.modules[module]; ok {
return stores[name]
}
return nil
}
UnregisterStore removes the store identified by module and name.
If the store or module does not exist, it is a no-op.
{
sm.mu.Lock()
defer sm.mu.Unlock()
if stores, ok := sm.modules[module]; ok {
delete(stores, name)
if len(stores) == 0 {
delete(sm.modules, module)
}
}
}
Snapshot returns a deep copy of all registered stores and their states.
{
snap := make(map[string]map[string]map[string]any)
sm.mu.RLock()
defer sm.mu.RUnlock()
for module, stores := range sm.modules {
snap[module] = make(map[string]map[string]any)
for name, store := range stores {
stateCopy := make(map[string]any)
for k, v := range store.state {
stateCopy[k] = v
}
snap[module][name] = stateCopy
}
}
return snap
}
NewStoreManager creates a standalone manager for isolating store instances.
{
return &StoreManager{modules: make(map[string]map[string]*Store)}
}
NewStore creates a new store with the given name and optional configuration.
By default stores are registered under the "default" module.
{
return GlobalStoreManager.NewStore(name, opts...)
}
Map registers a computed value derived from a single dependency using a
strongly typed mapping function. The mapping function receives the current
value of the dependency and its result is stored under the provided key. If
the dependency cannot be asserted to the expected type, the zero value of the
return type is used instead.
{
c := NewComputed(key, []string{dep}, func(m map[string]any) any {
if v, ok := m[dep].(T); ok {
return compute(v)
}
var zero R
return zero
})
s.RegisterComputed(c)
}
Map2 registers a computed value derived from two dependencies. The mapping
function receives the current values of both dependencies and its result is
stored under the provided key. If any dependency fails type assertion the
zero value of the return type is used.
{
c := NewComputed(key, []string{depA, depB}, func(m map[string]any) any {
a, okA := m[depA].(A)
b, okB := m[depB].(B)
if okA && okB {
return compute(a, b)
}
var zero R
return zero
})
s.RegisterComputed(c)
}
{
for _, s := range slice {
if s == item {
return true
}
}
return false
}
{
if key == dep {
return true
}
if strings.HasPrefix(key, dep+".") {
return true
}
if strings.HasPrefix(dep, key+".") {
return true
}
return false
}
{
snap := make(map[string]any, len(deps))
for _, d := range deps {
snap[d] = state[d]
}
return snap
}
{
rv := NewReactiveVar(0)
var changed int
rv.OnChange(func(v int) { changed = v })
rv.Set(42)
if got := rv.Get(); got != 42 {
t.Fatalf("expected Get to return 42, got %d", got)
}
if changed != 42 {
t.Fatalf("expected OnChange to fire with 42, got %d", changed)
}
}
{
initial := sample{A: 1, B: "foo"}
rv := NewReactiveVar(initial)
var changed sample
rv.OnChange(func(s sample) { changed = s })
newVal := sample{A: 2, B: "bar"}
rv.Set(newVal)
if got := rv.Get(); !reflect.DeepEqual(got, newVal) {
t.Fatalf("expected Get to return %v, got %v", newVal, got)
}
if !reflect.DeepEqual(changed, newVal) {
t.Fatalf("expected OnChange to receive %v, got %v", newVal, changed)
}
}
effect represents a reactive computation registered via Effect.
{
if e.cleanup != nil {
e.cleanup()
e.cleanup = nil
}
for _, dep := range e.deps {
dep.remove(e)
}
e.deps = nil
prev := currentEffect
currentEffect = e
e.cleanup = e.run()
currentEffect = prev
}
{
if e.cleanup != nil {
e.cleanup()
e.cleanup = nil
}
for _, dep := range e.deps {
dep.remove(e)
}
e.deps = nil
}
Signal holds a value of type T and tracks which effects depend on it.
NewSignal creates a new Signal with the given initial value.
{
s := &Signal[T]{value: initial, subs: make(map[*effect]struct{})}
s.id = registerSignal(initial)
return s
}
Effect registers a reactive computation that automatically re-runs when its
dependent signals change. The provided function may return a cleanup function
that will run before the next execution and when the effect is stopped.
{
e := &effect{run: fn}
e.runEffect()
return e.stop
}
{
sm := &StoreManager{modules: make(map[string]map[string]*Store)}
s := NewStore("test", WithModule("mod"))
sm.RegisterStore("mod", "test", s)
if sm.GetStore("mod", "test") == nil {
t.Fatalf("expected store to be registered")
}
sm.UnregisterStore("mod", "test")
if sm.GetStore("mod", "test") != nil {
t.Fatalf("expected store to be unregistered")
}
}
import "testing"
import "context"
import "strings"
import "testing"
import "testing"
import "github.com/rfwlab/rfw/v1/js"
js
import "fmt"
import "testing"
import "testing"
import "github.com/rfwlab/rfw/v1/js"
js
import "encoding/json"
import "github.com/rfwlab/rfw/v1/js"
js
import "sync"
import "context"
import "testing"
import "testing"
import "log"
import "reflect"
import "strings"
import "sync"
import "reflect"
import "testing"
import "testing"