astwerk

reactive — state

Signals, effects, computed values and ownership — the state model behind the bindings.

reactive is opt-in state management on top of wasmwrap, fine-grained rather than virtual-DOM based: a binding updates exactly the property it owns, and a keyed list moves nodes rather than rebuilding them. This page is the state model — signals, effects and ownership. The rest of the package is split across DOM bindings, routing & async and templ in the browser.

The lightweight path is x: the same reactive model, compiled to plain JavaScript at build time, with no WASM and no framework. See both running on the Examples page.

Documentation

Signal

count := reactive.NewSignal(0)

count.Get()    // read — subscribes if inside an Effect
count.Peek()   // read without subscribing
count.Set(5)
count.Update(func(v int) int { return v + 1 })

Set compares against the current value and does nothing if they're equal, so a redundant write wakes nobody.

Subscribe

unsub := count.Subscribe(func(v int) {
	fmt.Println(v)
})
unsub()

A manual subscription: fires on every change, runs outside effect tracking, and returns an unsubscribe function. Effects use the same mechanism internally.

Effect

reactive.Effect(func() {
	if useA.Get() {
		fmt.Println(a.Get()) // only a is a dependency
	} else {
		fmt.Println(b.Get()) // only b is
	}
})

Runs immediately and again whenever a signal it read changes. Dependencies are tracked, not declared — the set is re-collected on every run, so a branch not taken creates no dependency.

Why an effect runs immediately

The first run is how the effect discovers what it depends on: every signal read while it runs registers as a dependency. Nothing is declared up front, so there is no list to keep in sync with the code.

Why dependencies are re-collected every run

Each re-run unlinks the effect from everything it read last time and collects afresh as it goes. That is what makes a branch not taken free: flip useA and the effect re-runs, drops a, and only b keeps it alive.

Batch

reactive.Batch(func() {
	first.Set("Ada")
	last.Set("Lovelace")
})

Collapses many writes into one update per affected effect; batches nest.

Untracked

v := reactive.Untracked(func() string { return count.Get() })

Runs fn without recording any dependencies — reads inside don't subscribe the enclosing effect. Peek is the single-signal version of this.

Computed and Memo

full := reactive.Computed(func() string {
	return first.Get() + " " + last.Get()
})

full.Get() // "Ada Lovelace"

Computed returns a *Memo[T] — a signal whose value is derived from other signals. It recomputes when its inputs change and is itself trackable, so derived values chain. If the recomputed value equals the previous one, downstream effects don't run.

m := reactive.Computed(fn)
m.Get()        // like any signal
m.Peek()       // without subscribing
m.Subscribe(f) // manual subscription
m.Dispose()    // detach from inputs, stop recomputing

Scope and OnCleanup

dispose := reactive.Scope(func() {
	reactive.BindText(a, )
	reactive.BindText(b, )
})
dispose() // tears down both

Effects nest: one created inside another is disposed and rebuilt when the outer re-runs, so bindings inside a rebuilt view don't accumulate. OnCleanup hooks into the same lifecycle. Every Bind* returns a dispose func; inside an effect it's owned for you.

Threading

Go on js/wasm runs one goroutine at a time, so signals need no locking. A goroutine may still write one — that's how Resource reports its result — and the effects it triggers run on that goroutine.

Example

count := reactive.NewSignal(0)
doubled := reactive.Computed(func() int { return count.Get() * 2 })

reactive.Effect(func() {
	fmt.Println(doubled.Get()) // 0, then 6 after the Set below
})

count.Set(3)

A signal, a derived value, and an effect that tracks both — Set(3) recomputes the memo and re-runs the effect exactly once.