useStorageObservable
The RxJS path for "store → reactive in a component". Equivalent to
useStorageSubscribe, but you can pipe RxJS operators (debounceTime,
scan, bufferTime, …) on top of the state stream. See the Reactive reads
overview. The examples use the end-to-end todoStorage (TodoState = { todos: Todo[]; filter: Filter }).
Basic usage
import { useStorageObservable } from 'synapse-storage/react'
// whole state
const state = useStorageObservable(todoStorage)
// a slice — emits only when the slice changes (distinctUntilChanged)
const total = useStorageObservable(todoStorage, (s) => s.todos.length)Internally it's a memoizing wrapper over toObservable + useObservable. The
observable is memoized by [storage], so the hook does not re-subscribe on every render. That's
enough when you just need a slice. But you can't attach your own operators through
useStorageObservable — for that drop one level down: toObservable (builds the stream) + useObservable
(subscribes and returns the value to render).
Operators on top of the stream
toObservable(storage, selector) gives an Observable<slice> you can pipe any RxJS operators onto. To
keep the subscription stable (instead of recreating it every render), wrap building the stream in a
factory and pass it to useObservable — it subscribes in useEffect and returns the latest value.
import { useObservable } from 'synapse-storage/react'
import { toObservable } from 'synapse-storage/reactive'
import { debounceTime, map } from 'rxjs/operators'
function TodoBadge() {
// todoStorage is a module-level singleton, stable ref → deps can be omitted
const label = useObservable(
() =>
toObservable(todoStorage, (s) => s.todos.length).pipe(
debounceTime(200),
map((count) => `${count} todos`),
),
'0 todos',
)
// label is a plain string, render it as is
return <div className="badge">{label}</div>
}useObservable returns a ready value (a string here) you can drop straight into JSX. Until the first
emit it shows initialValue ('0 todos'). The stream emits the initial value on subscribe, so the badge
doesn't flash empty.
Why debounce here
Without operators the badge would recompute on every todos change. With debounceTime(200) — if a
burst of changes arrives within 200 ms (bulk add, import), the component updates once with the final
value instead of 10 times in a row. That's the point of the RxJS path: smooth the stream before it reaches
render.
About deps — what goes in
The third argument of useObservable is the dependency array for re-subscription. Rule of thumb:
everything the factory closes over that can change goes into deps.
Singleton store (module constant) — stable ref, nothing to re-subscribe to.
depscan be omitted (the factory default is[], the subscription is built once on mount).[todoStorage]is also correct here, just redundant.Store from props / context /
useCreateStorage— the ref can change. Then[storage]is required, otherwise the stream stays subscribed to the old instance (stale).The factory closes over external values (a
limitprop, a selecteduserId, etc.) — put them indeps, otherwise the chain won't rebuild when they change and will run with the stale closure.
// store from props + external limit prop used inside pipe → both in deps
const recent = useObservable(
() =>
toObservable(store, (s) => s.items).pipe(
map((items) => items.slice(0, limit)),
),
[],
[store, limit],
)Example: debounced search
The live input value and the "heavy" search result are two different reactive reads. The input value must update instantly (a plain subscription), while filtering should only run once the user stops typing (a debounced stream):
import { useStorageSubscribe, useObservable } from 'synapse-storage/react'
import { toObservable } from 'synapse-storage/reactive'
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
function SearchBox() {
// 1) live input value — updates on every keystroke
const query = useStorageSubscribe(searchStorage, (s) => s.query)
// 2) results — recomputed only after the user idles for 300 ms
const matches = useObservable<Product[]>(
() =>
toObservable(searchStorage, (s) => s.query).pipe(
map((q) => q.trim().toLowerCase()),
debounceTime(300),
distinctUntilChanged(),
map((q) => (q ? filterProducts(q) : [])),
),
[],
)
return (
<div>
<input
value={query}
placeholder="Search…"
onChange={(e) =>
searchStorage.update((s) => {
s.query = e.target.value
})
}
/>
<ul>
{matches.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
</div>
)
}The input doesn't lag (the value from useStorageSubscribe is immediate), and filterProducts runs once
every 300 ms after a pause instead of on every keystroke.
Example: a notification aggregator
The classic case: 10 messages arrive within a couple of seconds — you want to show one notification
"10 new messages", not 10 toasts. That's a side-effect (call toast.show), not a render value, so we use
useSubscription instead of useObservable — it subscribes and renders nothing.
Model: messagesStorage holds { inbox: Message[] }, each new message is pushed into inbox.
import { useSubscription } from 'synapse-storage/react'
import { toObservable } from 'synapse-storage/reactive'
import { bufferTime, filter, map, pairwise } from 'rxjs/operators'
function MessageNotifier() {
useSubscription(
() =>
toObservable(messagesStorage, (s) => s.inbox.length)
.pipe(
pairwise(), // [was, now]
map(([prev, next]) => next - prev), // how many were added
filter((added) => added > 0), // arrivals only (not removals)
bufferTime(2000), // collect events for 2 seconds
filter((batch) => batch.length > 0), // skip empty windows
map((batch) => batch.reduce((sum, n) => sum + n, 0)), // total per window
)
.subscribe((count) => {
toast.show(count === 1 ? 'New message' : `${count} new messages`)
}),
[],
)
return null
}How it reads:
pairwise+mapturn "inbox length" into "how many were added this tick";bufferTime(2000)collects those arrivals in 2-second windows;per window — one
toast.showwith the total.
So a burst of 10 messages within 2 seconds yields one toast "10 new messages". That's the answer to
"can the hook do this": once you're inside an Observable, the whole RxJS toolbox is available — you just
pick the right entry point (useObservable to render a value, useSubscription to run a side-effect).
Notes
A
toObservableselector stream already runs throughdistinctUntilChanged— an extradistinctUntilChangedright after the selector is almost always redundant.Need a simple reactive read without RxJS? Use useStorageSubscribe.
A stream outside React (effects, non-React code) — toObservable.