Dispatcher (in detail)
The full surface of the Dispatcher class. The assembly page shows
the minimum; here are all the factories (action / signal / apiActions / keyedApiActions /
watcher), the ofType rule for apiActions, and standalone use without createSynapse.
Same domain — pokemon-advanced. Action/watcher name = class field name.
Standalone use
Dispatcher works without createSynapse too — an IStorage is enough. In standalone mode the
instance is finalized lazily: names are assigned on the first call of any action or on the first access
to the dispatch/watchers registries.
import { MemoryStorage } from 'synapse-storage/core'
import { initialState } from './pokemon.store'
import { PokemonDispatcher } from './pokemon.dispatcher'
import type { PokemonState } from './pokemon.types'
const storage = new MemoryStorage<PokemonState>({ name: 'pokemon-advanced', initialState })
await storage.initialize()
const dispatcher = new PokemonDispatcher(storage)
dispatcher.selectPokemon(25) // actions — typed instance fieldsDispatcher surface
| Factory field | What it creates |
|---|---|
| this.action(fn) | an action with a handler (store, params) => result; payload = the returned value |
| this.signal<P>(desc) | a pure intent signal: (_store, p) => p, writes nothing to the store |
| this.apiActions<P>(accessor) | a callable group for an API request lifecycle |
| this.keyedApiActions<P>(accessor) | the same, but status is stored per key (Record<string, ApiRequestState>) |
| this.watcher(config) | a reactive watcher over part of the state |
this.action
this.action((store, params) => result) — a handler in the "recipe" signature. The action's payload =
the handler's return value.
class PokemonDispatcher extends Dispatcher<PokemonState> {
// With a parameter: return = payload (effects catch it — e.g. loadDetails on selectPokemon)
readonly selectPokemon = this.action((store, id: number | null) => {
store.update((s) => {
s.selectedPokemonId = id
if (id === null) s.selectedPokemon = null
})
return id
})
// A write with no returned payload (applying a request result) — payload = void
readonly applyPokemonDetails = this.action((store, details: PokemonDetails) =>
store.update((s) => { s.selectedPokemon = details }),
)
// With meta — arbitrary metadata (2nd argument of this.action)
readonly toggleFavorite = this.action(
(store, id: number) => {
store.update((s) => {
const idx = s.favorites.indexOf(id)
if (idx >= 0) s.favorites.splice(idx, 1)
else s.favorites.push(id)
})
return id
},
{ meta: { description: 'Add/remove from favorites' } },
)
// With memoize — a repeated call with the same argument is skipped (doesn't trigger search needlessly)
readonly setSearchQuery = this.action(
(store, query: string) => { store.set('searchQuery', query); return query },
{ memoize: (current, previous) => current === previous },
)
}this.signal
A pure intent: writes nothing to the store, the payload is passed further to effects. description
goes into meta. In pokemon that's how loadMore is built — the signal itself doesn't change state,
the loadMore effect picks it up (and drives the status through loadList.*).
class PokemonDispatcher extends Dispatcher<PokemonState> {
readonly loadMore = this.signal<void>('Load the next page')
}this.apiActions (callable group + lifecycle)
apiActions returns a callable group. Calling the group itself is init (an intent): it resets the
status to idle and passes the payload to effects. The lifecycle — through field-methods. The
accessor points to the ApiRequestState cell in the state.
class PokemonDispatcher extends Dispatcher<PokemonState> {
readonly loadList = this.apiActions<void>((s) => s.api.listRequest)
readonly loadDetails = this.apiActions<void>((s) => s.api.detailsRequest)
}
// Usage:
d.loadList() // init: listRequest status → idle, the intent goes to effects
d.loadList.loading() // status → loading
d.loadList.success() // status → success
d.loadList.failure('msg')// status → error, error = 'msg'
d.loadList.reset() // status → resetIn the pokemon effects the group is used exactly like this: ofType(d.loadList) starts the request,
d.loadList.loading() / .success() / .failure() drive the status — see Effects.
Rule: ofType(d.loadList) catches ONLY init
// In an effect: we react to the INTENT to load (init), not to statuses
action$.pipe(ofType(d.loadList), /* ... start the request ... */)
// To react to a RESULT — listen for the specific phase explicitly:
action$.pipe(ofType(d.loadList.success), /* ... */)
action$.pipe(ofType(d.loadList.failure), /* ... */)keyedApiActions works the same way, but status is stored per key
(Record<string, ApiRequestState>), and init/loading/success/reset accept a key, while
failure accepts { key, error }. Handy when a single endpoint loads in parallel for different
entities (e.g. details of several pokemon with a status per id):
// hypothetical cell: api.detailsByIdRequest: Record<string, ApiRequestState>
readonly loadDetailsById = this.keyedApiActions<{ key: string }>((s) => s.api.detailsByIdRequest)
d.loadDetailsById({ key: '25' }) // init under key '25'
d.loadDetailsById.loading('25')
d.loadDetailsById.failure({ key: '25', error: 'msg' })this.watcher
A reactive watcher over a slice of state, returning an RxJS Observable. In pokemon —
watchFavoriteCount (with meta and notifyAfterSubscribe).
class PokemonDispatcher extends Dispatcher<PokemonState> {
// Basic + notifyAfterSubscribe (fire immediately on subscribe) + meta
readonly watchFavoriteCount = this.watcher({
selector: (state) => state.favorites.length,
notifyAfterSubscribe: true,
meta: { description: 'tracking the number of favorites' },
})
// With shouldTrigger — filtering out false triggers
readonly watchSelected = this.watcher({
selector: (state) => state.selectedPokemonId,
shouldTrigger: (prev, current) => prev !== current,
})
}
// Subscribe — through the watchers registry (calling the factory → Observable)
const sub = dispatcher.watchers.watchFavoriteCount().subscribe((action) => {
console.log('favorites:', action.payload)
})
sub.unsubscribe()Reserved field names
The names storage, action$, actions, dispatch, watchers, use, destroy are members of the
base class, they cannot be used as action/watcher names. A field-alias (one action under two names)
is rejected at finalization with a clear error.
Usage
// Calling actions — through the instance's typed fields
dispatcher.selectPokemon(25)
dispatcher.setSearchQuery('pika')
dispatcher.loadMore()
// Or through the dispatch registry
dispatcher.dispatch.selectPokemon.actionType // '[pokemon-advanced]selectPokemon'
dispatcher.dispatch.toggleFavorite.meta // { description: 'Add/remove from favorites' }
// Subscribing to watchers (RxJS Observable)
const sub = dispatcher.watchers.watchFavoriteCount().subscribe((action) => {
console.log('favorites:', action.payload)
})
sub.unsubscribe()
// Subscribing to ALL actions (effects are built on this stream)
dispatcher.actions.subscribe((action) => {
console.log(action.type, action.payload)
})
// Cleanup
dispatcher.destroy()In a
createSynapseassembly the dispatcher is available asstore.dispatcher, andstore.actionsis an alias ofstore.dispatcher.dispatch. See createSynapse (dispatcher).