@pensarfeo/betterstore
v2.1.4
Published
Reactive store utilities with validation, persistence, and cross-tab sync
Maintainers
Readme
@pensarfeo/betterstore
Reactive store utilities built around a small writable core (Svelte-style change detection), with optional validation, persistence, and cross-context sync via BroadcastChannel.
Features
- Core
writable:set,get,observe,subscribe, pause/resume batching-friendly notifications Store: input/output transforms, sync validation, asyncrequestUpdate, persistence (main thread), multi-tab/worker syncBasicStore/BasicToggleable: minimal layer when you only need observe / subscribe / value (subpath import)- Composition:
Derived,GlobalStore,Selectable,Observer,Subscriber
Installation
npm install @pensarfeo/betterstoreInstall Svelte alongside this package when you use Observable.bind() (the library imports onDestroy from svelte at runtime). Svelte is not listed as a peerDependency in package.json yet—you must add it to your app.
Deep imports (e.g. store/core.js) work in this repo layout; published installs depend on those files being included in the package. There is no exports map in package.json today.
Quick start
import { Store } from '@pensarfeo/betterstore'
const count = new Store(0)
count.observe((value) => {
console.log('Count:', value)
}).run()
count.value = 10Entry point is store.js (package.json "main").
Exports: Store, Toggleable, Derived, GlobalStore, Selectable, Observer, Subscriber, partialSubscriber.
Core API (store/core.js)
Low-level primitive used by everything else. Matches Svelte’s “notify when meaningfully different” rule via safe_not_equal.
import { writable, safe_not_equal } from '@pensarfeo/betterstore/store/core.js'safe_not_equal(a, b)
Returns true when a and b should be treated as different for reactive updates (including NaN, objects, and functions). This is the same idea as Svelte’s store inequality check.
writable(initialValue)
Returns a plain object (not a Store instance):
| Member | Description |
|--------|-------------|
| get() | Current value. |
| set(value) | Updates only if safe_not_equal(current, value), then notifies subscribers with (newValue, previousValue). |
| observe(run, invalidate?) | Adds a subscriber. run(newValue, prevValue) is called after each effective set. invalidate (default no-op) runs before the flush queue for that subscriber. Returns an unsubscribe function. Does not call run immediately. |
| subscribe(run, invalidate?) | Same as observe, but calls run(current) once when subscribing. Returns an unsubscribe function. |
| run | Internal dispatch function used by set / resume; advanced use only. |
| pause() | Replace notifications with a no-op until resume / unpause. |
| resume() | Restore default notifier and immediately emit once with (current, prev). |
| unpause() | Restore default notifier without an immediate emit. |
Use Store / BasicStore observe + Observable.run() when you want “subscribe without an immediate call until you run”.
Observable (store/basic.js)
Store and BasicStore return this from observe(fn):
const ob = store.observe((v) => console.log(v))
ob.run() // invoke callback once with current `store.value`, chainable
ob.bind() // register `onDestroy(() => unsub)` in a Svelte component
ob.unsubscribe()BasicStore also exposes subscribe from writable (immediate call + ongoing updates).
On Store, subscribe(fn) is a convenience one-shot: it runs observe(fn).run() and then unsubscribes. Use observe(...).run() for a lasting subscription.
BasicStore / BasicToggleable (store/basic.js)
No validation, mutations, or async update path—only the core writable plus value, observe, and subscribe.
import { BasicStore, BasicToggleable } from '@pensarfeo/betterstore/store/basic.js'
const x = new BasicStore(0)
x.observe(fn).run()
// or: x.subscribe(fn) — runs fn immediately, stays subscribedBasicToggleable exposes toggle, makeTrue, makeFalse, and a read-only value getter (uses underlying set for writes).
Store (main Store constructor)
import { Store, partialSubscriber } from '@pensarfeo/betterstore'
const store = new Store(initialValue, {
name: 'my-store', // used for persistence key + multi-process channel id
persist: true, // main-thread localStorage only (see below)
multiProcess: true, // BroadcastChannel; browser main + worker (not Node)
})Copied from core writable
set, get, observe, subscribe, run, pause, resume, unpause are copied onto the instance. Calling set(value) updates the raw cell and does not go through validateInput / inputMutation / validate (unlike assigning store.value).
value
- Get:
outputMutation(get()) - Set: runs
validateInput→inputMutation→validate→ underlyingsetif allowed.
Mutations
mutateInput(fn)— replace input transform (single function, not a stack).mutateOutput(fn)— replace output transform when readingvalue.clearMutations()— reset both to identity.
Validation
addInputValidation(fn)—(newValue, currentValue) => truthy/falsey; runs onvalueassignment only (not on rawset()).addValidation(fn)— runs after input mutation, before store update; same(newValue, currentValue)shape.validate,validateInput— combined validators (updated as you add rules).
Note: requestUpdate runs async validators and validate, but not validateInput. Use the same shape of checks in async validators if you need them there too.
Async update
addAsyncInputValidation(fn)/deleteAsyncInputValidation(fn)—fn(args)may return a Promise;falseaborts the update.requestUpdate(args, beforeUpdate?)— setsupdatingtrue, runs async validations, theninputMutation, thenvalidate, then optionalbeforeUpdate(mutated)andset(mutated). Returns a boolean promise result (trueif the value was applied).updating—BasicToggleableflag whilerequestUpdateis in flight. Useswritable.subscribe(ongoing), notStore.subscribe(one-shot).
Object helpers
entry(key, value)— mutates object state in place (same reference) then assigns throughvalue.delete(key)— removes a key on the current object via the same path asvalue.
Subscription helpers
partialSubscriber(action, keys, fn)— builds apartialSubscriber(keys, fn)callback and passes it tothis[action], e.g.'observe'.
Utilities
addMethod(name, fn),addAttribute(name, value)unpersist()— stop persistence and remove the localStorage key (no-op if persistence was not enabled).
Persistence
Enabled when persist: true, env === 'main' (browser window), and localStorage is available. Uses name as the key; JSON round-trip must match the initial value’s typeof to be restored.
Multi-process
When multiProcess: true, name is required, and env is not 'node'. Uses two BroadcastChannel names derived from name (STOREBROADCAST_GET + STOREBROADCAST_POST + name). Both main and worker attach with observe(sendMessage).run(); workers also post on the request channel to pull the current value.
MultiProcess() returns a dispose function, but Store does not keep or expose it—there is no store.disposeMultiProcess() on instances.
comlink is listed in package.json dependencies for experimental code under store/multiProcess/interface.js. That path is not wired to Store’s multiProcess option (which uses store/multiProcess.js only).
Toggleable
new Toggleable(initialBoolean, options) — same options as Store. Boolean helpers: toggle, makeTrue / on, makeFalse / off, changeTo. Read-only value getter; writes go through setFn.
Async: requestToggle(), requestTrue(), requestFalse() delegate to requestUpdate. On success, requestTrue / requestFalse also call makeTrue / makeFalse after requestUpdate has already applied the value (redundant but harmless).
Derived
import { Derived } from '@pensarfeo/betterstore'
const d = new Derived([storeA, storeB], configOptional)- Subscribes to each source with
.observe(...).run(). - Internal value is an array of source values by index (
d.get()→[a, b, ...]). unsubscribe()— stop listening to all sources.bind()— forwards to each source subscription’sObservable.bind()(instance method, not a static onDerived).
There is no Derived.bind static helper in this package.
GlobalStore
import { GlobalStore } from '@pensarfeo/betterstore'
const s = GlobalStore(initialValue, {
name: 'required',
context: 'optional-namespace',
persist: false,
multiProcess: false,
})First call for a given context + name creates new Store(...); later calls return the same instance. Initial value on later calls is ignored once the store exists.
Selectable
import { Selectable } from '@pensarfeo/betterstore'
const sel = new Selectable(false) // initial value for each toggleable
const a = sel.add('a')
const b = sel.add('b')
sel.selected // Store holding the selected name or null
sel.bind()
sel.unsubscribe()Each add(name) returns a Toggleable. The intended behavior is radio-group selection: turning one on updates selected and turns the previous selection off.
See Known limitations — Selectable is not reliable with the current Store.subscribe implementation.
Observer
Event bus built on an internal Store holding the latest emit payload:
import { Observer } from '@pensarfeo/betterstore'
const bus = new Observer('events')
bus.observe((...args) => console.log(...args))
bus.emit(1, 2)
bus.once((x) => console.log('once', x))observe(fn)— returns anObservable;fnreceives spread arguments from the lastemit..run()is optional and only needed for a synchronous read before the firstemit.once(fn)— same asobserve, unsubscribes after the first notification.emit(...args)— notifies observers with those arguments.
Subscriber
Event bus like Observer, backed by an internal Store whose value is { payload: [...] } from the last emit:
import { Subscriber } from '@pensarfeo/betterstore'
const bus = new Subscriber()
bus.observe((...args) => console.log(...args))
bus.emit(1, 2)
bus.observeOnce((x) => console.log('once', x))| Method | Description |
|--------|-------------|
| observe(fn) | Returns an Observable. fn receives spread arguments from the last emit. Does not run until the next emit (same as Store.observe). |
| observeOnce(fn) | Same as observe, but unsubscribes after the first notification. Returns the Observable so you can call unsubscribe() early if needed. |
| emit(...args) | Sets internal state to { payload: args } and notifies observers; listeners receive ...args. |
Observer and Subscriber are interchangeable for many apps. Differences:
| | Observer | Subscriber |
|--|------------|--------------|
| One-shot API | once(fn) | observeOnce(fn) |
| Internal payload | emit args stored as an array | { payload: args } object |
| Optional name | stored on this.name (unused internally) | not used |
Prefer Observer for the shorter once name; use Subscriber when you want observeOnce naming aligned with observe, or when sharing the { payload } shape with other store-backed code.
Utility: partialSubscriber(keys, fn)
import { Store, partialSubscriber } from '@pensarfeo/betterstore'
const user = new Store({ name: 'A', age: 1, email: 'x' })
const sub = partialSubscriber(['name', 'age'], (obj) => console.log(obj))
user.observe(sub).run()Callback receives the full latest object; it only fires when one of the listed keys changes (by !==).
Environment
The library sets env to:
main—typeof window !== 'undefined'worker—WorkerGlobalScopenode— otherwise
Persistence runs only in main. Multi-process runs when env !== 'node' and name is set.
Known limitations
| Area | Detail |
|------|--------|
| Store.subscribe | One-shot only (observe → run → unsubscribe). Use observe(...).run() for ongoing listeners. |
| Selectable | add() wires children via Toggleable.subscribe (one-shot). Selection does not stay in sync; tests in test/selectable.test.js are skipped. |
| Selectable.bind / unsubscribe | Stores the return value of subscribe (an unsub function), not an Observable—.bind() on those entries does not match the Observable API. |
| Multi-process dispose | Not exposed on Store instances (see Multi-process above). |
| Experimental sync | store/multiProcess/interface.js and worker.js are not part of the public Store API. |
Migration notes
Older docs or forks may use names that changed in 2.x:
| Was | Now |
|-----|-----|
| sub | observe (deferred) or subscribe (immediate + ongoing on writable / BasicStore) |
| Subscribable | Observable |
| Legacy Subscriber (pre-2.x bus) | Observer (emit / once) or Subscriber (emit / observeOnce) |
| Derived with compute (a, b) => … | Derived aggregates source values into an array by index |
See MIGRATION.md for a short reference.
Example: Svelte
<script>
import { Store } from '@pensarfeo/betterstore'
const count = new Store(0)
count.observe((v) => console.log(v)).bind()
</script>
<button onclick={() => count.value++}>
{count.value}
</button>License
ISC
