Cross-module dependencies
TL;DR. Two different questions that are often confused:
dependencies— when to start effects: a gate that waits for other stores to be ready BEFORE starting effects (it does not delay core construction).dependencies: [otherSynapse].Cross-store connection — how modules exchange data: the 4 patterns below (read state, read selectors, react to actions, mediator). This is independent of
dependencies.
createSynapse({
storage: () => new MemoryStorage<PokemonState>({ name: 'pokemon-advanced', initialState }),
dependencies: [settingsStorage], // gate for the START of effects
effects: () => new PokemonEffects(api, toObservable(settingsStorage)), // pattern 1: read state
})Usually both go together: since an effect reads another store — that store is also put into
dependencies so that it's initialized by the time effects start.
One createSynapse can depend on another storage or module. dependencies is a gate for the
START of effects, not for construction: the core (storage/dispatcher/selectors) is assembled
synchronously right away, while waitForDependencies runs inside ready() before starting the
effects — by the time effects start, the dependencies are guaranteed to be initialized.
When you need dependencies / when you don't
Needed when the starting module's effects read the state/selectors/actions of another store — so that
by the time effects start it's ready (otherwise the first withLatestFrom would take an uninitialized
value).
Not needed when: the module doesn't depend on anyone; or the connection is purely at the selectors
level (cross-store DI is synchronous and doesn't require waiting for a start). dependencies isn't about
type safety and isn't about the DI itself — only about the timing of the effects' start.
Same domain — pokemon-advanced. It depends on a separate settingsStorage (pageSize).
The real case: pokemon → settingsStorage
settingsStorage is a standalone settings storage living outside the pokemon module:
// pokemon.settings.ts
import { MemoryStorage } from 'synapse-storage/core'
export interface PokemonSettings { pageSize: number }
export const settingsStorage = new MemoryStorage<PokemonSettings>({
name: 'pokemon-settings',
initialState: { pageSize: 12 },
})The pokemon module declares it in dependencies and folds settings$ into the effects:
// pokemon.synapse.ts
import { MemoryStorage } from 'synapse-storage/core'
import { toObservable } from 'synapse-storage/reactive'
import { createSynapse } from 'synapse-storage/utils'
export const pokemonSynapse = createSynapse({
storage: () => new MemoryStorage<PokemonState>({ name: 'pokemon-advanced', initialState }),
dependencies: [settingsStorage], // gate for the START of effects (core is built right away)
dependencyTimeout: 10000, // ms, default 30000
dispatcher: (s) => new PokemonDispatcher(s),
selectors: (s) => new PokemonSelectors(s),
// settings$ — the external store's state as an Observable (pattern 1, see below)
effects: async () => {
await initPokemonApi() // the async prologue moved into the effects factory
return new PokemonEffects(pokemonApiClient.getEndpoints(), toObservable(settingsStorage))
},
})A dependency can be (DependencyInput):
a raw
IStorage— likesettingsStorageabove (itsinitialize()is awaited for us);another synapse handle —
dependencies: [otherSynapse](the handle is itself thenable — noawaitneeded);any
PromiseLike<{ storage }>.
In the effects pageSize arrives via withLatestFrom(this.settings$) — see
Effects. Change settingsStorage.set('pageSize', 24) and the next list
load takes the new page size, without wiring the modules together directly.
Four patterns of cross-module communication
Pokemon uses pattern 1 (it reads settingsStorage's state). The other three are for richer links —
demonstrated by the Auth → Settings sandbox.
1. Read another store's STATE in effects — via toObservable
Exactly what pokemon does with the settings:
import { toObservable } from 'synapse-storage/reactive'
class PokemonEffects extends Effects<PokemonState, PokemonDispatcher> {
constructor(private readonly api: PokemonApiEndpoints, private readonly settings$: Observable<PokemonSettings>) {
super()
}
// this.settings$ is folded into the pipe via withLatestFrom → the apiCall takes pageSize
}
// assembly:
effects: new PokemonEffects(pokemonApiClient.getEndpoints(), toObservable(settingsStorage))2. Read another store's SELECTORS — via the Selectors constructor (cross-store)
External selectors come through the constructor and participate in this.combine(...) as reactive
dependencies (sandbox example — Settings depends on Auth):
import type { IStorage, SelectorAPI } from 'synapse-storage/core'
class SettingsSelectors extends Selectors<SettingsState> {
theme = this.select((s) => s.theme)
currentUserId: SelectorAPI<string | null>
constructor(storage: IStorage<SettingsState>, private auth: AuthSynapse['selectors']) {
super(storage)
// depends on ANOTHER store's selector → recomputes reactively
this.currentUserId = this.combine([this.auth.userId], (userId) => userId)
}
}
// assembly: cross-store DI SYNCHRONOUSLY — the C-form exposes `authSynapse.selectors` without await
createSynapse({
storage: () => new MemoryStorage<SettingsState>({ name: 'settings', initialState }),
dependencies: [authSynapse], // gate for the start of effects
selectors: (s) => new SettingsSelectors(s, authSynapse.selectors),
})
combineAcross/createLazyForeignSelectorare no longer needed and have been removed — the cross-store link is built directly through constructor DI (authSynapse.selectorsis available synchronously).
3. React to another store's ACTIONS — via externalDispatchers
External dispatchers are declared as the third generic Effects<…, Ext> and arrive in ctx.external
(their actions are already merged into the shared action$):
class SettingsEffects extends Effects<SettingsState, SettingsDispatcher, { auth: AuthDispatcher }> {
readonly onLogout = this.effect((action$, _state$, { dispatcher: d, external }) =>
action$.pipe(
ofType(external.auth.logout), // an action from ANOTHER module
tap(() => d.resetSettings()),
),
)
}
// in assembly the external dispatchers are wired in as externalDispatchers — a lazy slot function
// (doesn't force eager construction of the other store on import; resolved at the start of effects)
createSynapse({
storage: () => new MemoryStorage<SettingsState>({ name: 'settings', initialState }),
dependencies: [authSynapse],
dispatcher: (s) => new SettingsDispatcher(s),
effects: () => new SettingsEffects(),
externalDispatchers: () => ({ auth: authSynapse.dispatcher }),
})4. Mediator / event-bus
When modules shouldn't know about each other, they are linked by a separate mediator synapse (or
createEventBus): it is subscribed to the actions/states of both and relays events between them. More
details — createEventBus.
Initialization order
// The order in the C-form createSynapse:
// 1. Construction is SYNCHRONOUS: storage.initializeSync() → READY, dispatcher finalized,
// selectors materialized, state$ present — all available BEFORE ready()/await (cross-store DI)
// 2. ready()/await: waitForDependencies (Promise.all + timeout) — gate for the START of effects
// 3. Resolve the effects factory (may be async) + externalDispatchers → start the effects
// On timeout — an error is thrown (default 30000ms, pokemon uses 10000):
// 'Dependency 0 ("pokemon-settings") timed out after 10000ms. Check that it initializes correctly.'How to hand the assembled pokemonSynapse to React and await readiness — createSynapseCtx
and awaitSynapse. The full module — Pokemon (recipe).
See also
createSynapse (basic) — a realistic cross-store module (combine from other modules' selectors + several APIs + a socket).
createSynapse (effects) — pattern 1 (
toObservable+withLatestFrom) and pattern 3 (externalDispatchers+ctx.external) in action.Selectors — pattern 2: other modules' selectors in
this.combine.createEventBus — pattern 4: a mediator between modules.
awaitSynapse · createSynapseCtx — how to await
ready()in React.