toObservable
Turns a storage (IStorageBase) into an RxJS Observable of the state stream — for effects and
non-React code. It's the low-level utility that useStorageObservable is
built on. Imported from synapse-storage/reactive. The examples use the end-to-end todoStorage
(TodoState = { todos: Todo[]; filter: Filter }).
Signature
// whole state
toObservable<T>(storage: IStorageBase<T>): Observable<T>
// slice + optional comparator
toObservable<T, R>(
storage: IStorageBase<T>,
selector: (state: T) => R,
equals?: (a: R, b: R) => boolean,
): Observable<R>Three parameters:
storage— the storage.selector(optional) — which slice to pull out of the state.equals(optional) — how to compare adjacent slice values to skip duplicates.
selector — a slice instead of the whole state
Without a selector the stream emits the whole state on every store change — even when a field you
don't care about changed. With a selector the stream maps the state to a slice and runs it through
distinctUntilChanged, so it emits only when the slice actually changed:
import { toObservable } from 'synapse-storage/reactive'
const state$ = toObservable(todoStorage) // Observable<TodoState>, on any change
const count$ = toObservable(todoStorage, (s) => s.todos.length) // Observable<number>, only when it changesHere count$ won't fire if filter changed — the todos length is the same. That's the optimization:
the subscriber (component/effect) doesn't wake up on unrelated changes.
equals — how slices are compared
The third parameter is the comparator for distinctUntilChanged. It decides whether a new slice value is
"the same" (return true → the emission is skipped). By default the comparison uses Object.is.
Object.is is enough for primitives (number, string, boolean) and for slices with a stable
reference (when immutable updates don't touch that object — the reference is preserved). A custom
equals is needed in two cases:
1. The selector returns a new object/array every time. Then Object.is sees a new reference on every
tick and distinctUntilChanged won't de-dupe — the stream emits on every store change. Pass a by-content
comparison:
// factory selector: a new array each time → needs a by-value equals
const ids$ = toObservable(
todoStorage,
(s) => s.todos.map((t) => t.id),
(a, b) => a.length === b.length && a.every((id, i) => id === b[i]),
)2. You need a coarser equivalence than identity. For example, emit only when a property of the value changes, not the value itself:
// emits only when the parity of the count changes (1→3 stays quiet, 3→4 emits)
const parity$ = toObservable(todoStorage, (s) => s.todos.length, (a, b) => a % 2 === b % 2)
equalsonly makes sense together withselector— without a slice there's nothing to compare.
In effects
A typical case is wiring one storage's state as an external state into createEffectConfig:
const auth$ = toObservable(authStorage, (s) => s.user.id)
createEffectConfig: () => ({
externalStates: { userId: auth$ },
})Notes
The stream emits the current state immediately on subscribe (via
getStateSync()), then on every change.Under the hood it's
shareReplay({ refCount: true }): multiple subscribers share a single store subscription, and when their count drops to zero the stream unsubscribes from the storage (no leaked listeners).In a React component, do not create
toObservable(...)directly in render — memoize it (which is what useStorageObservable does) or subscribe viauseObservablewith a factory.For a simple reactive read in a component without RxJS, use useStorageSubscribe.