createSynapse (effects)
TL;DR. An effect = an RxJS recipe that listens to action$, performs a side-effect (an API call,
a socket) and dispatches the result back itself as actions. You declare effects as class fields via
this.effect(...); services and external stores come in through the constructor and are captured in the
closure. Key rule: an effect's emissions are NOT dispatched automatically — dispatch happens only
through direct d.* calls inside tap/apiResult.
readonly loadList = this.effect((action$, state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.loadList), // catch the intent
validateMap({ apiCall: () => fromRequest(this.api.getList.request({ limit: 12, offset: 0 })).pipe(
apiResult((data) => { d.applyPokemonList(data); d.loadList.success() }), // ← dispatch manually
) }),
),
)The last brick after the dispatcher: effects — the RxJS layer
of side actions. The dispatcher describes intents (loadList, selectPokemon, loadMore); an
effect listens for them in the stream, turns them into real API calls, and feeds the result back
into state through the dispatcher's actions.
Same domain — pokemon-advanced.
Why
Decouple intent from the network: the UI sends
loadList(), while how it loads (endpoint, cancellation of stale requests, statuses) is known only to the effect.RxJS orchestration: search debounce, cancelling in-flight requests (
switchMap), concurrency strategies for writes (exhaustMap/mergeMap), merging socket streams and neighboring stores — all declaratively.A single request machine:
validateMap/mutationMapgive a readyvalidation → loading → apiCall → success/errorcycle instead of manual subscriptions.
When to use / when it's NOT needed
Needed when: there are API calls/sockets/timers driven by user actions; you need to cancel stale requests, debounce, or react to streams from other modules.
NOT needed when: an action merely changes state synchronously — that's the job of the
Dispatcher (this.action), an effect would be redundant here. A module
with no network (pure local state) does without the effects layer entirely — the effects field is
omitted.
Effects (pokemon.effects.ts)
Effects are a class over Effects<State, Dispatcher>. Each effect is declared as a class field
via this.effect(...); the field name = the effect name. Services (API endpoints) and external
stores (settings$) come through the constructor and are captured in the recipe's closure.
import { Observable, withLatestFrom } from 'rxjs'
import { Effects, apiResult, fromRequest, ofType, selectorMap, selectorObject, validateMap } from 'synapse-storage/reactive'
import { mapDetailsResponse, mapListResponse, type PokemonApiEndpoints } from './pokemon.api'
import type { PokemonSettings } from './pokemon.settings'
import type { PokemonState } from './pokemon.types'
import type { PokemonDispatcher } from './pokemon.dispatcher'
export class PokemonEffects extends Effects<PokemonState, PokemonDispatcher> {
constructor(
private readonly api: PokemonApiEndpoints,
private readonly settings$: Observable<PokemonSettings>,
) {
super()
}
// loadList (init/idle) → validateMap → loading → API → success/failure
readonly loadList = this.effect((action$, state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.loadList),
withLatestFrom(selectorObject(state$, { listStatus: (s) => s.api.listRequest.status }), this.settings$),
validateMap({
// gate: don't refetch while a request is already in flight
validator: ([, { listStatus }]) => ({
conditions: [listStatus !== 'loading'],
skipAction: () => d.loadList.reset(),
}),
loadingAction: () => d.loadList.loading(),
errorAction: (err) => d.loadList.failure(String(err)),
apiCall: ([, , { pageSize }]) =>
fromRequest(this.api.getList.request({ limit: pageSize, offset: 0 })).pipe(
apiResult((data) => {
d.applyPokemonList({ ...mapListResponse(data), append: false })
d.loadList.success()
}),
),
}),
),
)
// selectPokemon → load the details of the selected pokemon
readonly loadDetails = this.effect((action$, state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.selectPokemon),
withLatestFrom(selectorMap(state$, (s) => s.selectedPokemonId, (s) => s.api.detailsRequest.status)),
validateMap({
validator: ([, [selectedId, detailsStatus]]) => ({
conditions: [selectedId !== null, detailsStatus !== 'loading'],
skipAction: () => d.loadDetails.reset(),
}),
loadingAction: () => d.loadDetails.loading(),
errorAction: (err) => d.loadDetails.failure(String(err)),
apiCall: ([, [selectedId]]) =>
fromRequest(this.api.getDetails.request({ id: selectedId! })).pipe(
apiResult((data) => {
d.applyPokemonDetails(mapDetailsResponse(data))
d.loadDetails.success()
}),
),
}),
),
)
}(loadMore is built like loadList, only with offset from state and append: true — see the
full file.)
this.effect
this.effect((action$, state$, ctx) => Observable) — a class field; the recipe function receives the
streams and a context, and returns an Observable. The stream's emitted values don't matter — what
matters are the side effects (API calls and dispatched actions) inside the pipe.
readonly loadList = this.effect((action$, state$, ctx) => {
const d = ctx.dispatcher // this module's typed dispatcher
return action$.pipe(
ofType(d.loadList), // filter the stream by the action we want
// ...processing, this.api.* calls, dispatching d.applyPokemonList(...)
)
})The recipe's ctx is { dispatcher, external }:
dispatcher— this module's class-dispatcher instance (ofType(d.x)+d.applyPokemonList(...));external— external dispatchers (their actions are already merged into the sharedaction$), available if a third generic is declared:class Effects<State, Dispatcher, ExternalDispatchers>.
Rule: a service from the constructor (
this.api) can be captured in the recipe's closure, but cannot be dereferenced directly in a field initializer — parameter properties are assigned AFTER the subclass's field initializers. That's why effects are arrows insidethis.effect, not values computed in place.
Optional teardown — override onDestroy() (close sockets, unsubscribe from an external source).
static nonEffectFields(since 5.4.0). The dev "forgotten effect" check scans every function-valued field of the instance and warns about those not wrapped inthis.effect. Constructor-injected function dependencies (getters/factories, e.g.resolveSocket: () => Socket) are not effects and would otherwise trigger a false warning. Mark them:typescriptclass PresenceEffects extends Effects<PresenceState, PresenceDispatcher> { static override nonEffectFields = ['resolveSocket'] constructor(private resolveSocket: () => PresenceSocketService) { super() } readonly connection = this.effect(/* … */) }
ofType / ofTypes
import { ofType, ofTypes } from 'synapse-storage/reactive'
// ofType — filter the stream by a single dispatcher action
action$.pipe(ofType(d.loadList))
// ofTypes — by several
action$.pipe(ofTypes([d.loadList, d.loadMore]))ofType takes the action itself (d.loadList), not a string — the type of action.payload is
inferred automatically. For apiActions the filter fires on the group's init call (d.loadList()),
not on .loading()/.success() — more in Dispatcher (detailed).
Reading state in an effect: selectorObject / selectorMap
Often, before a request, you need a slice of state (the current status, offset, pageSize). Take it
from state$ via withLatestFrom — it folds in the latest value without subscribing on every tick:
// selectorObject — a named slice (key → result)
withLatestFrom(selectorObject(state$, {
offset: (s) => s.offset,
hasMore: (s) => s.hasMore,
listStatus: (s) => s.api.listRequest.status,
}))
// selectorMap — a tuple of values (positional)
withLatestFrom(selectorMap(state$, (s) => s.selectedPokemonId, (s) => s.api.detailsRequest.status))External stores are folded in the same way — this.settings$ (from pokemon.settings) gives pageSize:
withLatestFrom(selectorObject(state$, { listStatus: (s) => s.api.listRequest.status }), this.settings$)
// → the pipe's value: [action, { listStatus }, { pageSize }]Handling requests: validateMap (reads) / mutationMap (writes)
For API effects the library ships two sibling operators with one shared vocabulary
(validator / loadingAction / errorAction / apiCall; success is dispatched inside apiCall
via apiResult). They wrap one pipe: [validator] → loadingAction → [prepare] → apiCall → success / errorAction.
fromRequest turns an ApiClient request into a cancellable Observable (aborts the HTTP request on
unsubscribe); apiResult unwraps a successful QueryResult (or throws ApiError, caught by errorAction).
validateMap — reads (resources)
Built on switchMap (last wins: a new trigger aborts the stale in-flight request). Perfect for
loading a resource where only the latest result matters — like loadList/loadDetails.
validatorreturns{ conditions, skipAction }:conditionsis an array of boolean gates (all must betrue), otherwiseskipActionis dispatched and no request is made. In pokemon that's "don't refetch while a request is in flight" (listStatus !== 'loading') and "there is something to load" (selectedId !== null).loadingAction→ sets theloadingstatus (via the dispatcher'sapiActionsgroup).apiCallreceives the pipe's value, callsfromRequest(this.api.X.request(...)), and insideapiResultwrites the result (d.applyPokemon...) +d.X.success().errorActioncatchesApiError→d.X.failure(...).
readonly loadDetails = this.effect((action$, state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.selectPokemon),
withLatestFrom(selectorMap(state$, (s) => s.selectedPokemonId, (s) => s.api.detailsRequest.status)),
validateMap({
validator: ([, [selectedId, detailsStatus]]) => ({
conditions: [selectedId !== null, detailsStatus !== 'loading'],
skipAction: () => d.loadDetails.reset(),
}),
loadingAction: () => d.loadDetails.loading(),
errorAction: (err) => d.loadDetails.failure(String(err)),
apiCall: ([, [selectedId]]) =>
fromRequest(this.api.getDetails.request({ id: selectedId! })).pipe(
apiResult((data) => {
d.applyPokemonDetails(mapDetailsResponse(data))
d.loadDetails.success()
}),
),
}),
),
)mutationMap — writes (mutations)
Same vocabulary, plus two write-specific concepts:
flatten— the concurrency strategy (an rxjs operator). Writes have no single right answer, so the caller picks it by meaning:exhaustMap— a single operation (create/update form): a double-submit is ignored, the in-flight request is not aborted;mergeMap— operations over different entities (delete/toggle/repost): real parallelism;concatMap— strictly one after another.
prepare— async request-body assembly (FormData, blobs, tags) beforeapiCall; its result arrives as the second argument ofapiCall. Noprepare→bodyisundefined.
Why not
validateMapfor writes?validateMapis locked toswitchMap, which aborts the in-flight request on a new trigger. On a write that's a hazard: a double-submit would cancel the first POST (which may have already committed server-side → lost response), and parallel ops over different entities would abort each other. So a mutation lets the caller choose the strategy.
import { ofType, mutationMap, fromRequest, apiResult } from 'synapse-storage/reactive'
import { exhaustMap, mergeMap } from 'rxjs/operators'
// create — single submit: exhaustMap (ignore double-submit), async body via prepare
createPost$ = this.effect((action$, _state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.createPost),
mutationMap({
flatten: exhaustMap,
loadingAction: () => d.createPost.loading(),
errorAction: (err) => d.createPost.failure(getErrorMessage(err)),
prepare: (payload) => buildCreateBody(this.api, payload),
apiCall: (_payload, body) =>
fromRequest(this.api.createPost.request({ body })).pipe(
apiResult((post) => { d.createPost.success(); d.prependPost(post) }),
),
}),
),
)
// delete — acts on different posts: mergeMap (parallel), no body
removePost$ = this.effect((action$, _state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.removePost),
mutationMap({
flatten: mergeMap,
errorAction: (err, id) => d.removePost.failure(getErrorMessage(err)),
apiCall: (id) =>
fromRequest(this.api.removePost.request({ id })).pipe(
apiResult(() => d.dropPost(id)),
),
}),
),
)validateMap is internally just mutationMap with flatten: switchMap and no prepare — the same
machine, the strategy is the only conceptual difference.
Not every effect is an API request. For simple cases (debouncing search, relaying into another action) a bare
action$.pipe(...)with ordinary rxjs operators is enough — see the Search sandbox withdebounceTime+switchMap.
Assembly
Effects plug into createSynapse like the other layers — but their constructor receives services and
external stores: the API endpoints (pokemonApiClient.getEndpoints()) and settings$
(toObservable(settingsStorage)).
import { MemoryStorage } from 'synapse-storage/core'
import { toObservable } from 'synapse-storage/reactive'
import { createSynapse } from 'synapse-storage/utils'
import { initPokemonApi, pokemonApiClient } from './pokemon.api'
import { settingsStorage } from './pokemon.settings'
import { initialState } from './pokemon.store'
import type { PokemonState } from './pokemon.types'
import { PokemonDispatcher } from './pokemon.dispatcher'
import { PokemonEffects } from './pokemon.effects'
import { PokemonSelectors } from './pokemon.selectors'
export const pokemonSynapse = createSynapse({
storage: () => new MemoryStorage<PokemonState>({ name: 'pokemon-advanced', initialState }),
dispatcher: (s) => new PokemonDispatcher(s),
selectors: (s) => new PokemonSelectors(s),
dependencies: [settingsStorage], // gate for the START of effects (not construction)
dependencyTimeout: 10000,
// the effects factory can be async — the whole async prologue moves here (init API client,
// resolve endpoints). Services and external stores are captured in the effects constructor closure.
effects: async () => {
await initPokemonApi() // async prologue: initialize the API client
return new PokemonEffects(pokemonApiClient.getEndpoints(), toObservable(settingsStorage))
},
})Effects start automatically when the module is initialized (the first await pokemonSynapse),
and only after dependencies (a gate for the start, not construction — the core is assembled right away).
More on the effects factory, dependencies and dependencyTimeout — Dependencies.
Return value
const store = await pokemonSynapse
store.storage // IStorage<PokemonState>
store.selectors // a PokemonSelectors instance
store.dispatcher // a PokemonDispatcher instance
store.actions // { loadList, selectPokemon, ... } (alias of store.dispatcher.dispatch)
store.state$ // Observable<PokemonState> — the state stream (always present)
// Kick off the chain with an intent — the effects drive the rest:
store.actions.loadList() // → effect loadList → API → applyPokemonList → success
store.actions.selectPokemon(25) // → effect loadDetails → API → applyPokemonDetailsHow to hand the assembled pokemonSynapse to React components — createSynapseCtx.
The full module — Pokemon (recipe).
See also
createSynapse (dispatcher) — the intents that effects listen to (
ofType).Dependencies and cross-store — 4 ways to connect modules (including
externalDispatchersand reading another module'sstate$in effects).ApiClient — endpoints/cache/tags,
fromRequest/apiResulton top of it.createSynapse (basic) — why the async prologue lives in the
effectsfactory.Pokemon (recipe) — effects as part of a whole module + the 5-state request protocol.