Documentation
¶
Overview ¶
Package state provides shared atom-based state management for GoWebComponents.
This package implements atom-based state management inspired by SolidJS and Jotai, allowing components to subscribe to global state that persists across the entire application and automatically triggers re-renders when values change.
Basic usage:
import "github.com/monstercameron/GoWebComponents/v4/state"
// In any component
func UserProfile() ui.Node {
username := state.UseAtom("currentUser", "Guest")
login := ui.UseEvent(func() {
username.Set("John Doe")
})
return html.Div(html.Props{},
html.H1(html.Props{}, html.Text(fmt.Sprintf("Welcome, %s", username.Get()))),
html.Button(html.Props{OnClick: login}, html.Text("Login")),
)
}
// In a different component - shares the same state!
func NavBar() ui.Node {
username := state.UseAtom("currentUser", "Guest")
return html.Nav(html.Props{}, html.Span(html.Props{}, html.Text(username.Get())))
}
Key features:
- Global state accessible from any component by ID
- Automatic subscription and re-rendering when state changes
- Type-safe with Go generics
- Thread-safe for concurrent access
- Subscription-scoped updates - only subscribed components re-render
- UseComputed for typed derived values inside components
- UseDerived for read-only shared derived atoms with explicit source dependencies
- Snapshot export/import for in-memory restore and optional browser persistence helpers
Atoms vs Component State:
- Use state.UseAtom for data that needs to be shared across components
- Use state.UseComputed for memoized derived values based on atoms, props, or local state
- Use state.UseDerived for shared read-only derived atoms keyed by ID and explicit source atom dependencies
- Use ui.UseState for local component state
- Atoms persist across component unmounts
- Atoms trigger updates in all subscribed components
Snapshot persistence notes:
- GetSnapshot and ApplySnapshot preserve exact Go values for same-process restore.
- SaveSnapshot and LoadSnapshot encode snapshots as JSON for browser storage.
- SavePersistentSnapshot and LoadPersistentSnapshot use IndexedDB-first durable storage with explicit fallback behavior.
- JSON persistence is only stable for JSON-compatible atom values; numeric and struct-heavy atoms may need caller-owned codecs if exact round-tripping is required.
Index ¶
- func ApplySnapshot(parseSnapshot Snapshot) error
- func ImportSnapshot(parseSnapshot Snapshot) errordeprecated
- func MarshalSnapshotJSON(parseSnapshot Snapshot) ([]byte, error)
- func RegisterSnapshotMigration(parseFromVersion int, parseMigration SnapshotMigration) error
- func RestorePersistentSnapshot(parseCtx context.Context, parseKey string, ...) (bool, error)
- func RestoreSnapshot(parseKey string, parseArea StorageArea) (bool, error)
- func SavePersistentSnapshot(parseCtx context.Context, parseKey string, parseSnapshot Snapshot, ...) error
- func SaveSnapshot(parseKey string, parseSnapshot Snapshot, parseArea StorageArea) error
- type Atom
- type AtomKey
- type Computed
- type ComputedSignal
- type Derived
- func Select[T any, U any](parseId string, parseSource selectorSource[T], parseProject func(T) U) Derived[U]deprecated
- func UseDerived[T any](parseId string, parseCompute func() T, parseDeps ...string) Derived[T]
- func UseSelector[T any, U any](parseId string, parseSource selectorSource[T], parseProject func(T) U) Derived[U]
- type Element
- type GlobalAtom
- type PersistentSnapshotOptions
- type Signal
- func (parseS Signal[T]) Get() T
- func (parseS Signal[T]) ID() string
- func (parseS Signal[T]) Peek() T
- func (parseS Signal[T]) ReactiveRegionSourceIDs() []string
- func (parseS Signal[T]) Set(parseValue T)
- func (parseS Signal[T]) Text(render func(T) string) *Element
- func (parseS Signal[T]) TextValue() *Element
- func (parseS Signal[T]) Update(parseFn func(T) T)
- type Snapshot
- func ExportSnapshot() (Snapshot, error)deprecated
- func GetSnapshot() (Snapshot, error)
- func LoadPersistentSnapshot(parseCtx context.Context, parseKey string, ...) (Snapshot, bool, error)
- func LoadSnapshot(parseKey string, parseArea StorageArea) (Snapshot, bool, error)
- func UnmarshalSnapshotJSON(parseData []byte) (Snapshot, error)
- type SnapshotMigration
- type StorageArea
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func ApplySnapshot ¶
ApplySnapshot merges atom values from snapshot into the global runtime and schedules subscribed components for updates.
func ImportSnapshot
deprecated
ImportSnapshot merges atom values from snapshot into the global runtime and schedules subscribed components for updates.
Deprecated: use ApplySnapshot. ImportSnapshot is the older export/import-terminology name and is kept only for source compatibility.
func MarshalSnapshotJSON ¶
MarshalSnapshotJSON serializes snapshot for browser storage or transport.
Persisted snapshots should only contain JSON-compatible values if stable round-tripping is required. Composite Go structs restore as generic JSON objects unless callers provide their own typed serialization layer.
func RegisterSnapshotMigration ¶
func RegisterSnapshotMigration(parseFromVersion int, parseMigration SnapshotMigration) error
RegisterSnapshotMigration registers one adjacent snapshot schema migration from parseFromVersion to parseFromVersion+1. Register migrations at app boot before loading stored snapshots. Passing nil removes the migration.
func RestorePersistentSnapshot ¶
func RestorePersistentSnapshot(parseCtx context.Context, parseKey string, parseOptions ...PersistentSnapshotOptions) (bool, error)
RestorePersistentSnapshot loads a durable snapshot and imports it into the current runtime.
If parseCtx is nil it is replaced with context.Background(). If parseCtx is already cancelled the underlying store resolver will receive a cancelled context and is expected to return an error, which is propagated to the caller.
func RestoreSnapshot ¶
func RestoreSnapshot(parseKey string, parseArea StorageArea) (bool, error)
RestoreSnapshot loads a snapshot from browser storage and imports it.
func SavePersistentSnapshot ¶
func SavePersistentSnapshot(parseCtx context.Context, parseKey string, parseSnapshot Snapshot, parseOptions ...PersistentSnapshotOptions) error
SavePersistentSnapshot stores a JSON-encoded snapshot in IndexedDB-first durable browser storage.
If parseCtx is nil it is replaced with context.Background(). If parseCtx is already cancelled the underlying store resolver will receive a cancelled context and is expected to return an error, which is propagated to the caller.
func SaveSnapshot ¶
func SaveSnapshot(parseKey string, parseSnapshot Snapshot, parseArea StorageArea) error
SaveSnapshot stores a JSON-encoded snapshot in browser storage.
Types ¶
type Atom ¶
type Atom[T any] struct { // contains filtered or unexported fields }
Atom exposes shared read/write state keyed by ID.
func UseAtom ¶
UseAtom provides shared global atoms with subscription-scoped rerenders. Atoms are accessible from anywhere in the component tree by ID and automatically trigger re-renders in all subscribed components when updated.
The first component to call UseAtom with a specific ID initializes the atom with the provided initial value. Subsequent calls from other components will use the existing value and subscribe to updates.
Type parameter T can be any Go type. The hook uses generic type parameters for type safety.
Returns an Atom[T] handle with Get, Set, and Update methods.
Example - Theme Management:
// In a theme-switcher component
theme := state.UseAtom("appTheme", "light")
toggle := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if theme.Get() == "light" {
theme.Set("dark")
} else {
theme.Set("light")
}
return nil
})
// In a header component — re-renders automatically when theme changes
theme := state.UseAtom("appTheme", "light")
bgColor := "white"
if theme.Get() == "dark" {
bgColor = "#333"
}
Example - User Authentication:
type User struct {
ID int
Username string
Email string
}
user := state.UseAtom("currentUser", User{})
login := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
user.Set(User{ID: 1, Username: "johndoe", Email: "john@example.com"})
return nil
})
if user.Get().ID == 0 {
// render login button
} else {
// render welcome message using user.Get().Username
}
Thread Safety: UseAtom is thread-safe and can be safely called from multiple goroutines. The internal atom registry uses mutex-based synchronization.
Cleanup: When a component unmounts, it is automatically unsubscribed from all atoms to prevent memory leaks and unnecessary updates.
Best Practices:
- Use descriptive atom IDs (e.g., "currentUser", "appTheme", "shoppingCart")
- Initialize atoms with appropriate default values
- Use structured types (structs) for complex state
- Avoid storing large amounts of data in atoms (use for coordination, not caching)
Example ¶
ExampleUseAtom shows shared atom state: components that bind the same atom id observe one value, and a Computed derives from it.
package main
import (
"github.com/monstercameron/GoWebComponents/v4/state"
)
func main() {
parseCount := state.UseAtom("counter", 0)
parseDoubled := state.UseComputed(func() int { return parseCount.Get() * 2 })
// Both update together when the atom changes.
_ = parseDoubled
}
Output:
func UseAtomKey ¶
UseAtomKey subscribes the current component to a typed atom key — the typed sibling of UseAtom. Prefer it over UseAtom for any atom shared across components or packages, so the id, type, and default are declared once and can't drift between call sites.
func (Atom[T]) ReactiveRegionSourceIDs ¶
ReactiveRegionSourceIDs is a core package helper.
type AtomKey ¶
type AtomKey[T any] struct { // contains filtered or unexported fields }
AtomKey is a typed, declared-once handle to a shared atom. Declaring the id, value type, and default in ONE package-level var —
var ThemeAtom = state.NewAtomKey("app.theme", "light")
and passing that key to UseAtomKey / ThemeAtom.Global() everywhere the atom is read or written gives three things the stringly UseAtom(id, default) form cannot:
- Type + default consistency: a UseAtom("app.theme", "light") and a NewGlobalAtom("app.theme", 0) elsewhere silently disagree on type and default; with a key they share the one declaration.
- A compile-checked name: a typo in the key var is a compile error, whereas a typo'd id string silently creates a brand-new, empty atom.
- A stringless call site: you pass ThemeAtom, not "app.theme" + "light" repeated everywhere.
Two keys that nonetheless choose the same id string still address the same atom — AtomKey centralizes the contract, it does not namespace ids. Share one key var for one logical atom. (Corollary: an AtomKey[string] and an AtomKey[int] declared with the SAME id hit one registry slot; whichever seeds first wins and the other's reads fall back to its default — exactly the raw-UseAtom type-mismatch footgun, not a new one. For a pointer/slice/map T, Default() hands every caller the same reference, so mutating it aliases; keep defaults immutable.)
func NewAtomKey ¶
NewAtomKey declares a typed atom key. Call it once at package scope and reuse the returned value.
func (AtomKey[T]) Default ¶
func (parseKey AtomKey[T]) Default() T
Default returns the key's seeded default value.
func (AtomKey[T]) Global ¶
func (parseKey AtomKey[T]) Global() GlobalAtom[T]
Global returns the out-of-render handle for this key — the typed sibling of NewGlobalAtom — so code outside a component can read or write the atom: ThemeAtom.Global().Set("dark"). The default is seeded only if the atom has no value yet (it never clobbers an existing write).
type Computed ¶
type Computed[T any] struct { // contains filtered or unexported fields }
Computed exposes a memoized derived value local to the current component.
func UseComputed
deprecated
UseComputed derives a typed value from other state used by the current component.
It is intended for render-time derived values, especially when a component is already reading one or more atoms and wants a typed handle instead of using ui.UseMemo directly in every call site.
The computed value is memoized according to the provided dependency list. Callers should pass the values that should trigger recomputation.
Deprecated: prefer ui.UseMemo, which returns the value directly (UseComputed only wraps it in a Computed handle). For a shared, cross-component derived value use state.UseDerived instead.
type ComputedSignal ¶
type ComputedSignal[T any] struct { // contains filtered or unexported fields }
ComputedSignal is a read-only value derived from one or more reactive sources.
Its dependencies are EXPLICIT — passed to NewComputed — so recomputation is predictable and never depends on a hidden tracking graph. ComputedSignal.Get is lazy (it recomputes from the live sources on read); pass the computed to ui.ReactiveRegion to render a subtree that updates fine-grained when any named source changes.
Multi-source note: Get, Text, and ReactiveRegionSourceIDs honor ALL declared sources. The one exception is using a ComputedSignal as an explicit source of ANOTHER computed (via the reactiveSource interface): only its primary (first) source id is propagated, so chain a computed off its underlying atoms — not off another multi-source computed — if you need every transitive dependency tracked.
func NewAutoComputed ¶
func NewAutoComputed[T any](parseCompute func() T) ComputedSignal[T]
NewAutoComputed derives a value from compute, AUTO-DISCOVERING its reactive sources by running compute once with read-tracking on and recording which signals it read (Solid-style auto-tracking). It is the opt-in sibling of NewComputed; explicit dependency declaration (NewComputed) remains the default and recommended path per GWC's no-hidden-graph design, so auto-tracking is a convenience, not a replacement.
a := state.NewSignal(2)
b := state.NewSignal(3)
sum := state.NewAutoComputed(func() int { return a.Get() + b.Get() }) // a, b discovered
func NewComputed ¶
func NewComputed[T any](parseCompute func() T, parseSources ...reactiveSource) ComputedSignal[T]
NewComputed derives a value from compute, declaring the reactive sources it depends on. Dependencies are explicit by design: only changes to the named sources are guaranteed to refresh consumers bound through ui.ReactiveRegion.
first := state.NewSignal("Ada")
last := state.NewSignal("Lovelace")
full := state.NewComputed(func() string { return first.Get() + " " + last.Get() }, first, last)
func (ComputedSignal[T]) Get ¶
func (parseC ComputedSignal[T]) Get() T
Get recomputes and returns the derived value from the live sources.
func (ComputedSignal[T]) Peek ¶
func (parseC ComputedSignal[T]) Peek() T
Peek returns the derived value; identical to ComputedSignal.Get, named for parity with Signal.Peek.
func (ComputedSignal[T]) ReactiveRegionSourceIDs ¶
func (parseC ComputedSignal[T]) ReactiveRegionSourceIDs() []string
ReactiveRegionSourceIDs returns every declared source id, so ui.ReactiveRegion re-renders the region when ANY dependency of the computed changes.
func (ComputedSignal[T]) Text ¶
func (parseC ComputedSignal[T]) Text(render func(T) string) *Element
Text renders a fine-grained reactive text node for the computed value. It subscribes to EVERY declared source, so the text flushes when any dependency changes — a multi-source computed is no longer a silent missed-update footgun (it previously bound only the first source). The node recomputes the full computed value on each flush.
func (ComputedSignal[T]) TextValue ¶
func (parseC ComputedSignal[T]) TextValue() *Element
TextValue is the zero-argument form of Text for a computed signal: it renders the computed value with default fmt formatting (see Signal.TextValue).
type Derived ¶
type Derived[T any] struct { // contains filtered or unexported fields }
Derived exposes a shared read-only derived atom keyed by ID.
func UseDerived ¶
UseDerived registers and subscribes to a read-only derived atom.
Derived atoms are shared state values keyed by id. They recompute when one of the named source atom IDs changes and expose a typed read-only handle to the current derived value. Dependency tracking is explicit through atom IDs so recomputation remains predictable and avoids hidden runtime graph discovery.
The id is GLOBAL and shared — this is intentional and is the whole point: two components that pass the same id share one derived atom (the same model as UseAtom). That deliberately differs from UseSelector, whose id is auto-scoped to the calling component because a selector is a component-local projection, not shared state. So: pick a unique, descriptive id for a UseDerived you intend to share, exactly as you would for a UseAtom; reach for UseSelector when you want a local, collision-free projection instead.
func UseSelector ¶
func UseSelector[T any, U any](parseId string, parseSource selectorSource[T], parseProject func(T) U) Derived[U]
UseSelector creates a read-only projected shared value from an atom or derived source.
The selector remains explicit: callers provide the derived ID to register and the source handle to project from. When the projected value is unchanged, subscribers are not notified, which makes it suitable for fine-grained hot-value paths.
func (Derived[T]) ReactiveRegionSourceIDs ¶
ReactiveRegionSourceIDs is a core package helper.
type GlobalAtom ¶
type GlobalAtom[T any] struct { // contains filtered or unexported fields }
GlobalAtom is a non-hook handle to a shared atom, readable and writable from ANY context — crucially from OUTSIDE a render: global keyboard handlers, undo/redo, post-decrypt hydration, network/event callbacks, or any goroutine.
It targets the same atom registry as UseAtom, keyed by the same id, so a component that reads the id via UseAtom(id, default) re-renders automatically when a GlobalAtom write changes the value. This is the answer to G39: it retires the render-phase "capture variable + captured bool" triad and removes the pre-render-write silent-drop bug — a value written through GlobalAtom before the first render persists (UseAtom seeds its default only when the atom is absent) and is observed by the first UseAtom read.
Contract: a GlobalAtom and any UseAtom sharing an id MUST agree on the default value. Construct a GlobalAtom once (e.g. a package var) and share it.
func NewGlobalAtom ¶
func NewGlobalAtom[T any](parseID string, parseDefault T) GlobalAtom[T]
NewGlobalAtom returns a handle for the atom identified by id and seeds the registry with defaultValue if (and only if) the atom has no value yet. Seeding never notifies subscribers and never clobbers an existing value, so it is safe to construct at package-init time or after an external write.
func (GlobalAtom[T]) Get ¶
func (parseAtom GlobalAtom[T]) Get() T
Get returns the current value, or the handle's default when the runtime is unavailable or the stored value is not of type T.
func (GlobalAtom[T]) ID ¶
func (parseAtom GlobalAtom[T]) ID() string
ID returns the atom's identifier (the key shared with UseAtom).
func (GlobalAtom[T]) Set ¶
func (parseAtom GlobalAtom[T]) Set(parseValue T)
Set writes a new value and schedules a re-render of every component subscribed to this id via UseAtom. Safe to call from any goroutine or callback. A no-op when the runtime is unavailable (e.g. native SSR with no global runtime).
Writing a value equal to the current one is a no-op (no re-render), matching UseState's behavior. Equality is a fast == with a structural reflect.DeepEqual fallback for non-comparable types (slice/map), so an equal slice/map is also a no-op.
func (GlobalAtom[T]) Update ¶
func (parseAtom GlobalAtom[T]) Update(parseFn func(T) T)
Update applies fn to the current value and stores the result.
type PersistentSnapshotOptions ¶
type PersistentSnapshotOptions struct {
// DatabaseName is the IndexedDB database to open (default used when empty).
DatabaseName string
// StoreName is the object store within the database (default used when empty).
StoreName string
// DeleteOnCorruption drops and recreates the store if it fails to open, trading data
// loss for availability instead of surfacing a hard error.
DeleteOnCorruption bool
// FallbackResolver supplies a synchronous Storage when IndexedDB is unavailable.
FallbackResolver func() (interop.Storage, error)
// FallbackBackend names the fallback for diagnostics (e.g. "localStorage").
FallbackBackend string
// StoreResolver overrides how the durable store is opened; when nil the default
// IndexedDB resolver is used.
StoreResolver func(context.Context) (interop.PersistentStore, error)
}
PersistentSnapshotOptions configures durable IndexedDB-first snapshot storage used by SavePersistentSnapshot / LoadPersistentSnapshot. The zero value is valid: it uses the default database and store names with no fallback. Set fields only to override defaults.
type Signal ¶
type Signal[T any] struct { // contains filtered or unexported fields }
Signal is a fine-grained reactive value: a terse, ergonomic handle over the shared atom registry that updates exactly the DOM nodes bound to it (via Signal.Text) and the regions/components subscribed to it — without forcing a re-render of the component that owns it.
It is the recommended fine-grained primitive. Compared to its neighbors:
- vs UseAtom: a Signal needs no caller-managed string id (one is minted), and it is created with NewSignal outside the hook/render lifecycle, so it can live in a package var, an event handler, or a goroutine.
- vs Solid/Svelte signals: GWC tracks derivation dependencies EXPLICITLY (NewComputed names its sources) rather than discovering them through a hidden runtime graph. That is a deliberate design choice — predictable, auditable reactivity over implicit magic.
A Signal is a small value (an id + default); copy it freely. Reads and writes go through the global atom registry and are safe from any goroutine.
func NewKeyedSignal ¶
NewKeyedSignal creates a signal with an explicit, shared id, so independent call sites can address the same reactive value (the way UseAtom does). Prefer NewSignal unless the shared identity is the point. Call sites sharing an id must agree on the initial value.
func NewSignal ¶
NewSignal creates a fine-grained reactive value seeded with initial.
It may be called anywhere — including outside a component render (package init, event handlers, goroutines) — because, unlike a hook, it does not depend on call order within a render. Each call mints a fresh, process-unique identity; use NewKeyedSignal when several call sites must address the same signal.
func (Signal[T]) Get ¶
func (parseS Signal[T]) Get() T
Get returns the current value of the signal. When called inside a NewAutoComputed compute function, it also records this signal as a discovered dependency (auto-tracking); outside one, the read-tracking check is a single cheap atomic load.
func (Signal[T]) ID ¶
ID returns the underlying atom id — the source key used by reactive regions and selectors, and the key a UseAtom reader would share.
func (Signal[T]) Peek ¶
func (parseS Signal[T]) Peek() T
Peek returns the current value without implying a reactive subscription. It is identical to Signal.Get today, named for parity with signal libraries and to document intent at call sites that must read a value without binding to it.
func (Signal[T]) ReactiveRegionSourceIDs ¶
ReactiveRegionSourceIDs lets a Signal drive a ui.ReactiveRegion, so a subtree can re-render on signal changes without rerunning its owner.
func (Signal[T]) Set ¶
func (parseS Signal[T]) Set(parseValue T)
Set writes a new value and notifies bound text nodes, reactive regions, and UseAtom subscribers of the same id. Writing a value equal to the current one is a no-op (no notification), matching UseState semantics. Safe from any goroutine; a no-op when the runtime is unavailable (e.g. native SSR).
func (Signal[T]) Text ¶
Text renders a fine-grained reactive text node bound to this signal: it updates in place when the signal changes, WITHOUT re-rendering the component that returned it. This is the primary fine-grained authoring path.
count := state.NewSignal(0)
// ...
h.Span(count.Text(func(n int) string { return fmt.Sprintf("%d", n) }))
func (Signal[T]) TextValue ¶
TextValue is the zero-argument form of Text: it binds a reactive text node that renders the signal's value with default fmt formatting — the common case (a string signal, or any value whose fmt form is what you want) where no custom formatter is needed.
name := state.NewSignal("Ada")
h.Span(name.TextValue()) // no render func
type Snapshot ¶
Snapshot stores exported atom values by atom ID.
func ExportSnapshot
deprecated
func GetSnapshot ¶
GetSnapshot returns a copy of all atoms currently registered in the global runtime.
The returned snapshot preserves in-memory Go values exactly for same-process restore via ApplySnapshot. When serializing to JSON or browser storage, only JSON-compatible atom values should be relied on as stable persisted data.
func LoadPersistentSnapshot ¶
func LoadPersistentSnapshot(parseCtx context.Context, parseKey string, parseOptions ...PersistentSnapshotOptions) (Snapshot, bool, error)
LoadPersistentSnapshot reads and decodes a snapshot from IndexedDB-first durable browser storage.
If parseCtx is nil it is replaced with context.Background(). If parseCtx is already cancelled the underlying store resolver will receive a cancelled context and is expected to return an error, which is propagated to the caller.
func LoadSnapshot ¶
func LoadSnapshot(parseKey string, parseArea StorageArea) (Snapshot, bool, error)
LoadSnapshot reads and decodes a snapshot from browser storage.
func UnmarshalSnapshotJSON ¶
UnmarshalSnapshotJSON decodes a JSON snapshot produced by MarshalSnapshotJSON.
type SnapshotMigration ¶
SnapshotMigration upgrades one snapshot payload between adjacent schema versions. Implementations must return a fresh snapshot or an error; errors abort the restore before any atom state is changed.
type StorageArea ¶
type StorageArea string
StorageArea names a browser storage backend.
const ( // LocalStorage stores snapshots in window.localStorage. LocalStorage StorageArea = "localStorage" // SessionStorage stores snapshots in window.sessionStorage. SessionStorage StorageArea = "sessionStorage" )