Selectors
Selectors extract and compute data from a storage. They are memoized — recomputed only when their
dependencies change. They can be combined. In the class form, selectors are declared as class fields —
the fields are real SelectorAPI right away (eager materialization).
The examples use the end-to-end todoStorage (TodoState = { todos: Todo[]; filter: Filter }) from the
MemoryStorage section and its canonical TodoSelectors set.
1. The Selectors class
import { MemoryStorage, Selectors } from 'synapse-storage/core'
interface Todo { id: string; title: string; done: boolean }
type Filter = 'all' | 'active' | 'completed'
interface TodoState { todos: Todo[]; filter: Filter }
const todoStorage = new MemoryStorage<TodoState>({
name: 'todo',
initialState: { todos: [], filter: 'all' },
})
await todoStorage.initialize()
// The class is bound to the storage through the constructor.
class TodoSelectors extends Selectors<TodoState> {
readonly todos = this.select((s) => s.todos)
}
const selectors = new TodoSelectors(todoStorage)2. this.select — simple
const filterTodos = (todos: Todo[], filter: Filter) =>
filter === 'all' ? todos : todos.filter((t) => (filter === 'active' ? !t.done : t.done))
class TodoSelectors extends Selectors<TodoState> {
readonly todos = this.select((s) => s.todos)
readonly filter = this.select((s) => s.filter)
// With a custom equals (for arrays/objects, to avoid extra notifications)
readonly titles = this.select((s) => s.todos.map((t) => t.title), {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
name: 'titles', // an optional name for debugging
})
}Intermediate slices can be declared private — they aren't visible from the outside, but work as dependencies in this.combine.
3. this.combine — combined
Combined selectors depend on other selectors. They are recomputed only when their dependencies change.
class TodoSelectors extends Selectors<TodoState> {
readonly todos = this.select((s) => s.todos)
readonly filter = this.select((s) => s.filter)
// Chain: todos + filter -> visible tasks
readonly visibleTodos = this.combine([this.todos, this.filter], (todos, filter) =>
filterTodos(todos, filter),
)
// Values computed from a dependency
readonly activeCount = this.combine([this.todos], (todos) => todos.filter((t) => !t.done).length)
readonly completedCount = this.combine([this.todos], (todos) => todos.filter((t) => t.done).length)
}this.keyed — a parametric selector
class TodoSelectors extends Selectors<TodoState> {
// One SelectorAPI per key (cache by key). Compares values structurally by default.
readonly byId = this.keyed((id: string) => (s: TodoState) => s.todos.find((t) => t.id === id))
}
selectors.byId('t1').select() // a SelectorAPI for a specific idCross-store: external selectors through the constructor
A selector can depend on a selector of another store. External selectors come in as a constructor parameter —
parameter properties are assigned BEFORE the field initializers, so this.core is available in the fields.
import type { IStorage, SelectorAPI } from 'synapse-storage/core'
class PostsSelectors extends Selectors<PostsState> {
readonly list = this.select((s) => s.list)
// cross-store: recomputed reactively when the other store changes
readonly currentUserId: SelectorAPI<number | null>
constructor(storage: IStorage<PostsState>, private core: CoreSelectors) {
super(storage)
this.currentUserId = this.combine([this.core.profile], (p) => p?.id ?? null)
}
}More details on cross-module relations — Cross-module dependencies.
Aggregated source readiness. A combined selector (
this.combine) is considered ready only when its local source is ready and all sources of its dependencies are. For the cross-store case above,currentUserId.isSourceReady()returnstrueonly after both thePostsStatestore and thecorestore are ready. The same aggregation applies toonSourceStatusChange. A simple selector (this.select) is bound to its single source.
4. Reactive selector (selector.$)
Every selector has a .$ field — an Observable<T>. It emits the current value on subscription and on every
real change (the same semantics as subscribe). This lets you transform reads reactively —
not only in React.
Outside React
import { debounceTime, distinctUntilChanged } from 'rxjs/operators'
// A regular subscription
const sub = selectors.activeCount.$.subscribe((count) => console.log('active:', count))
sub.unsubscribe()
// Transformation straight in the stream
selectors.activeCount.$
.pipe(debounceTime(300), distinctUntilChanged())
.subscribe((count) => console.log('debounced:', count))In effects
selector.$ is convenient as an effect's source — for example, debouncing a search query:
class SearchEffects extends Effects<SearchState, SearchDispatcher> {
constructor(private readonly selectors: SearchSelectors) { super() }
readonly autoSearch = this.effect((_action$, _state$, { dispatcher: d }) =>
this.selectors.searchQuery.$.pipe(
debounceTime(300),
distinctUntilChanged(),
tap((query) => d.search(query)),
),
)
}In React — useObservable / useSubscription
import { useObservable, useSubscription } from 'synapse-storage/react'
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'
function TodoStats() {
// useObservable — renders a derived value from the selector's stream.
// deps recreate the chain (important for stateful operators like debounceTime/scan).
const debouncedActive = useObservable(
() => selectors.activeCount.$.pipe(debounceTime(300), distinctUntilChanged(), map((n) => `${n}`)),
'0',
[],
)
// useSubscription — an imperative side-effect with no value returned to render.
useSubscription(
() => selectors.activeCount.$.pipe(distinctUntilChanged()).subscribe((n) => console.log('changed:', n)),
[],
)
return <div>active (debounced): {debouncedActive}</div>
}5. useSelector — React hook (current value)
import { useSelector } from 'synapse-storage/react'
function TodoList() {
// Basic usage — returns T | undefined
const visible = useSelector(selectors.visibleTodos)
const active = useSelector(selectors.activeCount)
// With withLoading — returns { data: T, isLoading: boolean }
const { data: todos, isLoading } = useSelector(selectors.todos, { withLoading: true })
if (isLoading) return <div>Loading...</div>
return <div>{visible?.map((t) => <div key={t.id}>{t.title}</div>)}</div>
}6. Programmatic access to a selector
// select() — get the current value
const value = selectors.activeCount.select()
// selectSync() — synchronous read from the cache
const value = selectors.activeCount.selectSync()
// subscribe() — manual subscription to changes
const unsub = selectors.activeCount.subscribe({
notify: (value) => console.log('active:', value),
})
unsub()
// Metadata
selectors.activeCount.getId() // the selector's unique ID
selectors.activeCount.isSourceReady() // are ALL of the selector's sources ready?
// For a combined selector, isSourceReady() aggregates the readiness of all dependency
// sources (important for cross-store). onSourceStatusChange — subscribe to this readiness:
const unsub2 = selectors.activeCount.onSourceStatusChange((isReady) => {
console.log('sources ready:', isReady)
})
unsub2()