createSynapse (basic)
createSynapse(config) assembles the data-management layer into a single lazy module. The
only form is a synchronous config object (C-form): storage (a factory for synchronous
storage), optionally dispatcher / selectors / dependencies / effects. Core construction is
synchronous; everything async (endpoints, sockets, dependency readiness) moves into the effects
lifecycle. The minimal form is storage + selectors, with no dispatcher or effects: changes go
through storage directly. We'll add the dispatcher and effects on the next pages
(Dispatcher, Effects).
Everything on one domain — pokemon-advanced (see the Pokemon example).
Here we take exactly two bricks from it: pokemon.store.ts and pokemon.selectors.ts.
Storage and state (pokemon.store.ts)
import type { PokemonState } from './pokemon.types'
export const initialState: PokemonState = {
api: {
listRequest: { status: 'idle', error: null },
detailsRequest: { status: 'idle', error: null },
},
pokemonList: [],
offset: 0,
hasMore: true,
selectedPokemonId: null,
selectedPokemon: null,
searchQuery: '',
favorites: [],
}Selectors (pokemon.selectors.ts)
Selectors are derived values. Class fields become real SelectorAPIs right after construction
(eager), the selector name = the field name. Intermediate slices can be kept private — invisible
from outside, but they work as dependencies in combine.
import { Selectors } from 'synapse-storage/core'
import type { PokemonState } from './pokemon.types'
export class PokemonSelectors extends Selectors<PokemonState> {
// private = an intermediate slice, not exported outside
private readonly api = this.select((s) => s.api)
// Simple selectors — a single state field
readonly pokemonList = this.select((s) => s.pokemonList)
readonly searchQuery = this.select((s) => s.searchQuery)
readonly favorites = this.select((s) => s.favorites)
// Combined ones — depend on other selectors and are recomputed memoized
readonly isListLoading = this.combine([this.api], (a) => a.listRequest.status === 'loading')
// Filter the list by the search string
readonly filteredList = this.combine([this.pokemonList, this.searchQuery], (list, query) =>
query ? list.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : list,
)
// Favorites — the intersection of the list and the ids in favorites
readonly favoriteCount = this.combine([this.favorites], (favs) => favs.length)
readonly favoritePokemon = this.combine([this.pokemonList, this.favorites], (list, favs) =>
list.filter((p) => favs.includes(p.id)),
)
}The full set of selectors (statuses and errors of both requests,
selectedPokemon,hasMore) is inpokemon.selectors.ts. More on selectors themselves — Selectors.
Assembly: createSynapse(config)
createSynapse(config) returns a lazy handle. The factories (storage/dispatcher/
selectors) run lazily — on the first await / ready() (or on the first synchronous access to
.storage/.selectors), not on import (this matters for SSR and for keeping a module import from
hitting the network). Yet core construction itself is synchronous: storage is driven to
READY in a single tick, and state$ is always present — even before await.
The minimal form — storage + selectors only:
import { MemoryStorage } from 'synapse-storage/core'
import { createSynapse } from 'synapse-storage/utils'
import { PokemonSelectors } from './pokemon.selectors'
import { initialState } from './pokemon.store'
import type { PokemonState } from './pokemon.types'
export const pokemonSynapse = createSynapse({
// storage — a factory for SYNCHRONOUS storage (Memory/LocalStorage); TState is inferred from it
storage: () => new MemoryStorage<PokemonState>({ name: 'pokemon-advanced', initialState }),
selectors: (s) => new PokemonSelectors(s),
// dispatcher / effects — we'll add them on the next pages
})
export type PokemonSynapse = Awaited<typeof pokemonSynapse>
TState(thePokemonStatetype) is inferred from thestoragefactory — no need to spell out generics by hand. If the type is awkward to infer from the factory, there's an explicit formcreateSynapse.of<State, Dispatcher, Selectors>({ … }).
The return value
// The handle is thenable: await starts effects and returns the assembled module
const store = await pokemonSynapse
// The result (basic — no dispatcher):
store.storage // IStorage<PokemonState> — the storage
store.selectors // a PokemonSelectors instance — fields = SelectorAPI
store.state$ // Observable<PokemonState> — the state stream (ALWAYS present, even without effects)
store.dispatcher // undefined (no dispatcher)
store.actions // undefined (the dispatcher alias)
// The C-form exposes the main core SYNCHRONOUSLY (no await) — the basis of cross-store DI:
pokemonSynapse.storage // IStorage<PokemonState> — available immediately
pokemonSynapse.selectors // PokemonSelectors — can be passed into other selectors' constructors
pokemonSynapse.state$ // Observable<PokemonState>
// The handle itself:
pokemonSynapse.ready() // Promise<store> — same as await (starts effects)
pokemonSynapse.isReady() // boolean
pokemonSynapse.getSnapshot() // store | undefined — synchronous access (needed for SSR)
pokemonSynapse.destroy() // Promise<void> — cleanup + memoization reset (the handle is recreatable)Usage in React
Without a dispatcher we read through useSelector and write through storage directly:
import { useSelector } from 'synapse-storage/react'
const filteredList = useSelector(store.selectors.filteredList)
const favoriteCount = useSelector(store.selectors.favoriteCount)
const searchQuery = useSelector(store.selectors.searchQuery)
// State change — directly through storage
store.storage.set('searchQuery', 'pika')
store.storage.update((s) => {
const i = s.favorites.indexOf(25)
if (i >= 0) s.favorites.splice(i, 1)
else s.favorites.push(25)
})Direct
storage.set/updateis fine for simple state. As soon as named intents and side-effects (loading from an API) appear — that's the job of Dispatcher and Effects.
Async lives in the effects factory
Core construction is synchronous, so everything async lives in the effects factory: it can be
async and lazily resolves browser-only resources (an API client's init(), endpoints, the ApiClient
IndexedDB cache, sockets) — after the core is built, and only on the client:
export const pokemonSynapse = createSynapse({
storage: () => new MemoryStorage<PokemonState>({ name: 'pokemon-advanced', initialState }),
selectors: (s) => new PokemonSelectors(s),
// async — only here; core construction and rendering don't touch it
effects: async () => new PokemonEffects(await getPokemonEndpoints()),
})Server and client build the store identically from
initialState, the SSR shell is inferred automatically (see SSR), and a module import doesn't hit the network. Howeffectslooks together with the dispatcher and dependencies — Effects and the Pokemon example.
Full shape — every config field
You usually need 2–3 fields, but here is the entire surface of the C-form at once (commented-out fields are optional), so you can see what it can do:
import { MemoryStorage } from 'synapse-storage/core'
import { createSynapse } from 'synapse-storage/utils'
export const pokemonSynapse = createSynapse(
{
// 1. storage — the ONLY required field. A factory for a synchronous storage
// (MemoryStorage/LocalStorage). TState is inferred from its type.
storage: () => new MemoryStorage<PokemonState>({ name: 'pokemon-advanced', initialState }),
// 2. dispatcher — a factory for the class dispatcher (intents + store updates). Gets storage.
dispatcher: (s) => new PokemonDispatcher(s),
// 3. selectors — a factory for the class selectors. Gets storage; this is also where
// cross-store DI goes (ANOTHER module's selectors are available synchronously):
// new X(s, coreSynapse.selectors).
selectors: (s) => new PokemonSelectors(s),
// 4. dependencies — the gate for STARTING effects (not construction): the core is built
// immediately, effects wait for these stores/modules to be ready. An item is an IStorage,
// a synapse handle, or any PromiseLike<{ storage }>.
dependencies: [settingsStorage],
// 5. dependencyTimeout — the dependencies wait timeout, ms (defaults to 30000).
dependencyTimeout: 10000,
// 6. externalDispatchers — foreign dispatchers whose actions are merged into the shared
// action$ (communication pattern 3). A lazy slot-function is preferred — it doesn't force
// eager construction of the foreign store on import; resolved when effects start.
externalDispatchers: () => ({ settings: settingsSynapse.dispatcher }),
// 7. effects — an effects factory; MAY be async (the whole async prologue goes here).
// ctx = { storage, dispatcher, selectors, deps }. Returns Effects instance(s) /
// effect functions / undefined.
effects: async ({ selectors }) =>
new PokemonEffects(await getPokemonEndpoints(), selectors),
},
{
// 8. The SECOND argument — options. postConstruct: a synchronous hook after core construction
// (storage READY, dispatcher finalized), BEFORE the first render. The home for normalizing
// persisted state (clearing transient flags).
postConstruct: ({ actions }) => actions.resetTransient(),
},
)A realistic large-project module: cross-store DI + several APIs + a socket
The minimal examples above show the shape. But in a real app a module rarely lives in isolation: its
selectors combine its own state with someone else's (data from other modules), and its effects hit
several APIs at once, listen to a WebSocket, and react to the streams of neighboring stores.
Here is how it all comes together, on a messenger domain (chat) that depends on authSynapse (the
current user) and settingsSynapse (settings: blocked users).
1. Selectors: combine from your own + foreign selectors (cross-store DI)
Foreign selectors arrive as constructor parameters and take part in this.combine([...]) on equal
footing with your own — the combined selector recomputes both when your store changes and when the
foreign one does:
import { Selectors, type IStorage } from 'synapse-storage/core'
import type { AuthSelectors } from '../auth/auth.selectors'
import type { SettingsSelectors } from '../settings/settings.selectors'
import type { ChatState } from './chat.types'
export class ChatSelectors extends Selectors<ChatState> {
constructor(
storage: IStorage<ChatState>,
private readonly auth: AuthSelectors, // ← selectors of a FOREIGN module
private readonly settings: SettingsSelectors, // ← and one more
) {
super(storage)
}
// own slices
private readonly messages = this.select((s) => s.messagesByConversation)
readonly activeId = this.select((s) => s.activeConversationId)
readonly connection = this.select((s) => s.connectionStatus)
readonly activeMessages = this.combine([this.messages, this.activeId], (byConv, id) =>
id ? byConv[id] ?? [] : [],
)
// cross-store: own messages + foreign currentUserId (auth) + foreign blockedUsers (settings).
// Recomputes when ANY of the three stores changes.
readonly visibleMessages = this.combine(
[this.activeMessages, this.auth.currentUserId, this.settings.blockedUsers],
(msgs, myId, blocked) =>
msgs
.filter((m) => !blocked.includes(m.authorId))
.map((m) => ({ ...m, mine: m.authorId === myId })),
)
readonly unreadCount = this.combine([this.messages, this.auth.currentUserId], (byConv, myId) =>
Object.values(byConv).flat().filter((m) => !m.readBy.includes(myId!)).length,
)
}⚠️ Cross-store
combinepitfall. Iftsconfigcompiles withuseDefineForClassFields: true(the default attarget: ES2022+), parameter properties (this.auth) are assigned after field initializers → at the moment ofthis.combine([this.auth.x])the dependency is stillundefined. Synapse catches this with a clear dev error. Fixes: either"useDefineForClassFields": false, or create such selectors inside the constructor body aftersuper(storage).
2. Effects: several APIs + a socket + a neighboring store's stream
All external resources — REST endpoints of two APIs, a WebSocket service, an Observable of a
neighboring store — arrive through the constructor and are captured in the effects' closure:
import { type Observable, tap } from 'rxjs'
import { Effects, apiResult, fromRequest, ofType, validateMap } from 'synapse-storage/reactive'
import type { MessagesApiEndpoints } from './messages.api'
import type { UsersApiEndpoints } from './users.api'
import type { ChatSocketService } from './chat.socket'
import type { PresenceState } from '../presence/presence.types'
import type { ChatState } from './chat.types'
import type { ChatDispatcher } from './chat.dispatcher'
export class ChatEffects extends Effects<ChatState, ChatDispatcher> {
constructor(
private readonly messagesApi: MessagesApiEndpoints, // REST #1
private readonly usersApi: UsersApiEndpoints, // REST #2
private readonly socket: ChatSocketService, // WebSocket service
private readonly presence$: Observable<PresenceState>, // a neighboring store's stream
) {
super()
}
// Conversation history on selection (REST #1)
readonly loadHistory = this.effect((action$, state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.openConversation),
validateMap({
loadingAction: () => d.loadHistory.loading(),
errorAction: (err) => d.loadHistory.failure(String(err)),
apiCall: (action) =>
fromRequest(this.messagesApi.getHistory.request({ conversationId: action.payload })).pipe(
apiResult((data) => {
d.applyHistory(data)
d.loadHistory.success()
}),
),
}),
),
)
// Pull in author profiles (REST #2)
readonly loadAuthors = this.effect((action$, state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.applyHistory),
validateMap({
apiCall: (action) =>
fromRequest(this.usersApi.getByIds.request({ ids: authorIds(action.payload) })).pipe(
apiResult((users) => d.applyAuthors(users)),
),
}),
),
)
// Incoming socket messages flow into the store. Dispatch is a side effect via tap
// (an effect's emissions are NOT dispatched automatically — only d.* calls are).
readonly incoming = this.effect((action$, state$, { dispatcher: d }) =>
this.socket.messages$.pipe(tap((msg) => d.messageReceived(msg))),
)
// Sending: action → socket.send
readonly send = this.effect((action$, state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.sendMessage),
tap((action) => this.socket.send(action.payload)),
),
)
// React to a NEIGHBORING store's stream (presence): mark interlocutors online/offline
readonly presenceSync = this.effect((action$, state$, { dispatcher: d }) =>
this.presence$.pipe(tap((presence) => d.applyPresence(presence.online))),
)
// Close the socket when the module is destroyed
override onDestroy() {
this.socket.disconnect()
}
}3. Wiring: pass everything into createSynapse
selectors receives foreign selectors synchronously (cross-store DI), dependencies holds the start
of effects until those modules are ready, and effects (async) lazily resolves both APIs and opens the
socket — on the client, after the core is constructed:
import { MemoryStorage } from 'synapse-storage/core'
import { createSynapse } from 'synapse-storage/utils'
import { authSynapse } from '../auth/auth.synapse'
import { settingsSynapse } from '../settings/settings.synapse'
import { presenceSynapse } from '../presence/presence.synapse'
import { getMessagesApi } from './messages.api'
import { getUsersApi } from './users.api'
import { connectChatSocket } from './chat.socket'
import { ChatDispatcher } from './chat.dispatcher'
import { ChatSelectors } from './chat.selectors'
import { ChatEffects } from './chat.effects'
import { initialState } from './chat.store'
import type { ChatState } from './chat.types'
export const chatSynapse = createSynapse({
storage: () => new MemoryStorage<ChatState>({ name: 'chat', initialState }),
dispatcher: (s) => new ChatDispatcher(s),
// cross-store DI: foreign selectors are available SYNCHRONOUSLY (the foreign module's main core is built lazily).
selectors: (s) => new ChatSelectors(s, authSynapse.selectors, settingsSynapse.selectors),
// effects START gate: wait until both modules whose state we read are ready.
dependencies: [authSynapse, settingsSynapse, presenceSynapse],
// async prologue: resolve TWO APIs + open the socket + a neighboring store's stream — client-only.
effects: async () =>
new ChatEffects(
await getMessagesApi(),
await getUsersApi(),
connectChatSocket(),
presenceSynapse.state$,
),
})What this demonstrates all at once: combine from n selectors (your own + two foreign modules),
multiple dependencies in dependencies, several APIs and a socket in one effects class,
and reading a neighboring store's stream via state$. The core construction stayed synchronous —
everything "heavy" moved into effects and doesn't get in the way of SSR.
Extras (DX)
browserStorage(config, { client })(exported fromsynapse-storage/core) — a server-safe storage factory:MemoryStorageon the server (nowindow),client(config)in the browser. Removes the manualconst isServer = typeof window === …+ branch; add client-only middleware (syncBroadcastMiddleware) insideclient. Both branches are a sync store of the same shape, and the type is inferred without manual generics.postConstruct— the second argumentcreateSynapse(config, { postConstruct }). A synchronous hook after core construction (storageREADY, dispatcher finalized), BEFORE the first render — for normalizing persisted state (clearing transient flags). A separate argument (not a config field) so the callback is contextually typed with the already-inferredTDispatcher:createSynapse.of<State, Dispatcher, Selectors>(config, options?)— the explicitly-typed C-form, for whenTStateis awkward to infer from thestoragefactory (manual generics without falling through to a constraint error):