# Synapse Storage — full documentation Source: https://synapse-homepage.web.app/docs · npm: synapse-storage · Generated: 2026-07-07 --- # Two layers: State Manager and Business Logic Layer Synapse is not "yet another state manager". It is **two independent layers**, and they are best understood separately: ``` synapse-storage │ ├── State Manager ← "where the state lives" │ └── synapse-storage/core │ MemoryStorage · LocalStorage · IndexedDBStorage · IStorage │ selectors (Selectors / SelectorModule) │ └── Business Logic Layer ← "how business logic manages the state" └── synapse-storage/reactive · /utils · /react Dispatcher · Effects · createSynapse · React hooks ``` ## Layer 1. State Manager — "where the state lives" These are the reactive **storages**. They answer a single question: *how to store state and subscribe to its changes*. A unified `IStorage` interface over three implementations: | Storage | When | |-----------------|-----------------------------------------------| | `MemoryStorage` | session-scoped state (most features) | | `LocalStorage` | synchronous persistence (settings, theme) | | `IndexedDBStorage` | asynchronous large data (cache, offline) | This layer also includes **selectors** — memoized derived state on top of a storage (like `reselect`, but with cross-store dependencies and a reactive `selector.$`). **This layer is self-sufficient.** You can take only `synapse-storage/core` without pulling in anything from the business logic: ```typescript import { MemoryStorage } from 'synapse-storage/core' const storage = new MemoryStorage({ name: 'counter', initialState: { count: 0 } }) await storage.initialize() storage.subscribe((s) => s.count, (count) => console.log(count)) storage.update((s) => { s.count++ }) // Immer-like ``` > No RxJS, no effects, no React — just a reactive storage. ## Layer 2. Business Logic Layer — "how logic manages the state" On top of a storage, the BL layer describes the **application's behavior**: which intents (actions) exist, how they change the state, and which network/reactive side effects they trigger. Three thin classes over the same engines: | Class | Role | |-----------------|---------------------------------------------------------------| | `Dispatcher` | intents and store updates. Action name = class field name | | `Selectors` | derived state (lives in the State Manager, but usually written alongside) | | `Effects` | side effects on RxJS (Redux-Observable style): network, sockets | | `createSynapse` | the "module" assembler — wires up storage, dispatcher, selectors and effects | This is **Synapse in the full sense** — the business logic management layer, whose shape resembles NestJS services/controllers: a class, dependencies through the constructor, field-methods. But without a heavy DI container — what's needed is the **shape**, not an IoC mechanism. ```typescript import { Dispatcher, Effects, ofType, validateMap, fromRequest, apiResult } from 'synapse-storage/reactive' import { Selectors, MemoryStorage } from 'synapse-storage/core' import { createSynapse } from 'synapse-storage/utils' // Intents and store updates. Action name = field name. class PostsDispatcher extends Dispatcher { loadPosts = this.apiActions((s) => s.api.postsRequest) // callable group applyPosts = this.action((store, page: Page) => store.update((s) => { s.list = page.data })) } // Derived state. Fields are real SelectorAPI right away (eager). class PostsSelectors extends Selectors { list = this.select((s) => s.list) isLoading = this.combine([this.list], () => /* ... */ false) } // Side effects. Services — through the constructor, captured in the closure. class PostsEffects extends Effects { constructor(private api: PostsApi) { super() } loadPosts = this.effect((action$, _state$, { dispatcher: d }) => action$.pipe( ofType(d.loadPosts), validateMap({ loadingAction: () => d.loadPosts.loading(), errorAction: (e) => d.loadPosts.failure(String(e)), apiCall: ([a]) => fromRequest(this.api.getPosts(a.payload)).pipe( apiResult((page) => { d.applyPosts(page); d.loadPosts.success() }), ), }), ), ) } // Module assembly — a lazy singleton handle. export const postsSynapse = createSynapse(async () => { const storage = new MemoryStorage({ name: 'posts', initialState }) return { storage, dispatcher: new PostsDispatcher(storage), selectors: new PostsSelectors(storage), effects: new PostsEffects(await getPostsApi()), } }) ``` ## Why this separation matters 1. **Take only what you need.** Need just a reactive IndexedDB cache — take the State Manager and don't pull in RxJS. Need a full module with networking — add the BL layer. `rxjs`/`react` are optional peer dependencies for exactly this reason. 2. **Responsibility boundary.** The State Manager knows nothing about intents and networking; the BL layer knows nothing about *how* the state is physically stored. Swap `MemoryStorage` for `IndexedDBStorage` — the business logic stays untouched. 3. **Testability.** A storage is tested as a data structure. A dispatcher — as a set of pure transitions. An effect — in isolation: `new PostsEffects(mockApi).loadPosts(action$, state$, ctx)` without spinning up the whole synapse. 4. **Mental model = NestJS.** `createSynapse` is a "module", dispatcher/effects are "services". A familiar shape for those coming from the backend, without the cost of a full-blown DI. --- # Install The core pulls in no extra dependencies. `rxjs` and `react` are only needed if you use the matching layer. ```bash npm install synapse-storage # or yarn add synapse-storage ``` Optional peer dependencies — install them as needed: ```bash # effects on RxJS npm install rxjs # React hooks and SSR npm install react ``` > Need only the reactive store? A single `synapse-storage` is enough — no `rxjs`, no `react`. --- # MemoryStorage In-memory storage. Data lives only while the page is open. Synchronous API. Every example in the State Manager section is built on a single end-to-end domain — a todo-list. It is the canonical store that is reused later in the "Working with data" and "Patterns" sections. ## Domain ```typescript export interface Todo { id: string title: string done: boolean } export type Filter = 'all' | 'active' | 'completed' export interface TodoState { todos: Todo[] filter: Filter } export const initialTodoState: TodoState = { todos: [ { id: 't1', title: 'Изучить Synapse', done: true }, { id: 't2', title: 'Собрать todo-приложение', done: false }, ], filter: 'all', } ``` ## Creating ```typescript import { MemoryStorage } from 'synapse-storage/core' // Via new export const todoStorage = new MemoryStorage({ name: 'todo', initialState: initialTodoState, }) // Or via the static .create() — a full equivalent const todoStorage = MemoryStorage.create({ name: 'todo', initialState: initialTodoState, }) // Initialization is required before use await todoStorage.initialize() ``` ## When to use - Ephemeral UI state: filters, forms, modal state, selected items. - State that must not survive a page reload. - The default baseline choice — when persistence isn't needed. ## When not to use - You need to keep data across reloads → [LocalStorage](./local-storage.md) or [IndexedDB](./indexeddb-storage.md). - Large amounts of data or binary data → [IndexedDB](./indexeddb-storage.md). ## Working with data Reading, writing, subscriptions, and selectors are the same for all synchronous storages and are covered in the "Working with data" section: - [Reading data](./reading-data.md) — `get`, `getState`, `getStateSync` - [Writing data](./writing-data.md) — `set`, `update`, `reset` - [remove / has / keys / clear / reset](./delete-has-keys.md) - [Subscriptions](./subscriptions.md) and [Selectors](./selector-system.md) ## Lifecycle ```typescript await todoStorage.initialize() // initialization await todoStorage.waitForReady() // waiting for readiness todoStorage.initStatus // { status: 'ready' } // Subscribing to status changes const unsub = todoStorage.onStatusChange((status) => { console.log(status) // { status: 'ready' | 'loading' | 'error' | 'idle' } }) await todoStorage.destroy() // destruction (for memory, clears the data) ``` --- # LocalStorage Data is stored in the browser's `localStorage` and survives page reloads. Synchronous API, fully identical to [MemoryStorage](./memory-storage.md). The same end-to-end todo domain (`TodoState`, `initialTodoState` — see [MemoryStorage](./memory-storage.md)), but now the tasks are persisted across reloads. ## Creating ```typescript import { LocalStorage } from 'synapse-storage/core' // Via new const storage = new LocalStorage({ name: 'todo-local', // the key in localStorage initialState: initialTodoState, }) // Or via the static .create() const storage = LocalStorage.create({ name: 'todo-local', initialState: initialTodoState, }) // initialize() loads saved data from localStorage, if any await storage.initialize() ``` ## When to use - Small user settings and state that should survive a reload (theme, selected filter, draft). - You want a synchronous API and simplicity — without async `await`. ## When not to use - Large amounts of data, arrays of thousands of items, or binary data → localStorage is limited (~5 MB) and serializes everything into a string. Use [IndexedDB](./indexeddb-storage.md). - Data must not survive the session → [MemoryStorage](./memory-storage.md). ## Working with data The write/read/subscription API is identical to MemoryStorage — see the "Working with data" section ([Reading](./reading-data.md), [Writing](./writing-data.md), [Subscriptions](./subscriptions.md)). The only difference is that data is automatically synchronized into localStorage; the key in localStorage equals the `name` field. ## destroy() and clearOnDestroy By default `destroy()` does **not** wipe the data in localStorage — the state survives storage destruction (persistent IndexedDB behaves the same way). The behavior is controlled by the `clearOnDestroy?: boolean` config flag (`SyncStorageConfig`): default `false` for `localStorage` and `true` for `memory` (ephemeral). To make `destroy()` clear localStorage, pass `{ clearOnDestroy: true }`. ## Persist migrations and SSR Since the data is persistent, when the shape of `initialState` changes between releases you can migrate it via `version` + `migrate` — see [Persist migrations](./persist-migration.md). Server state can be seeded via [`hydrate(state)`](./ssr-hydration.md). --- # IndexedDBStorage Data is stored in IndexedDB and survives reloads. **Asynchronous API** — read/write operations return a Promise. The same end-to-end todo domain (`TodoState`, `initialTodoState` — see [MemoryStorage](./memory-storage.md)), but in a persistent asynchronous storage. ## Creating ```typescript import { IndexedDBStorage } from 'synapse-storage/core' // options is a required field (may be an empty object) const storage = new IndexedDBStorage({ name: 'todo-idb', initialState: initialTodoState, options: {}, }) // With a custom dbName const storage = new IndexedDBStorage({ name: 'todo-idb', initialState: initialTodoState, options: { dbName: 'my_app_db' }, // defaults to 'app_storage' }) // Or via the static .create() const storage = IndexedDBStorage.create({ name: 'todo-idb', initialState: initialTodoState, options: {}, }) await storage.initialize() ``` ## Synchronous vs asynchronous API The key difference from Memory/LocalStorage: operations return a Promise. ```typescript // Writing await storage.set('filter', 'active') await storage.update((s) => { s.todos.push(createTodo('Новая задача')) }) // Reading const todos = await storage.get('todos') const state = await storage.getState() // getStateSync() — synchronous read from the cache, always available (including in render) const cached = storage.getStateSync() ``` Subscriptions (`subscribe`, `subscribeToAll`, `useStorageSubscribe`) are identical to synchronous storages. ## When to use - Large amounts of data, arrays of thousands of items, binary data (Blob/ArrayBuffer). - You need persistence beyond the localStorage limit (~5 MB). ## When not to use - Small state where you don't want asynchrony → [LocalStorage](./local-storage.md). - Ephemeral UI state → [MemoryStorage](./memory-storage.md). ## Working with data A full walkthrough of the operations is in the "Working with data" section: [Reading](./reading-data.md), [Writing](./writing-data.md), [remove/has/keys](./delete-has-keys.md), [Subscriptions](./subscriptions.md). Everywhere a synchronous storage returns a value, IndexedDB returns a Promise. ## Persist migrations and SSR IndexedDB is persistent, so it supports schema migration via `version` + `migrate` (the version is stored as a reserved record in the same store and isn't visible in `getState()`/`keys()`) — see [Persist migrations](./persist-migration.md). Server state is seeded via [`hydrate(state)`](./ssr-hydration.md) (for IndexedDB — `await storage.hydrate(...)`). --- # WorkerCacheStorage `WorkerCacheStorage` is an **asynchronous** storage (`extends AsyncBaseStorage`, `type: 'worker'`) whose data lives inside a **SharedWorker** instead of the tab. It mirrors the key/path semantics of [MemoryStorage](./memory-storage.md) key-for-key — the difference is **where** the `Map` lives: in a **live** in-memory cache inside a SharedWorker, shared across every tab of the origin. Think "MemoryStorage that other tabs can see too". ## Creating ```typescript import { WorkerCacheStorage } from 'synapse-storage/core' // Live cross-tab cache held inside a SharedWorker const storage = new WorkerCacheStorage({ name: 'todo-worker', initialState: initialTodoState, options: {}, // channelName defaults to config.name }) await storage.initialize() // Or via the static .create() const storage = WorkerCacheStorage.create({ name: 'todo-worker', initialState: initialTodoState, }) ``` The API is identical to any other async storage — `await set/get/update/delete`, `getStateSync()`, subscriptions. See [Reading](./reading-data.md), [Writing](./writing-data.md), [remove/has/keys](./delete-has-keys.md), [Subscriptions](./subscriptions.md). Stores that share the same `channelName` (defaults to `name`) share the same cache — that is how two tabs, or an [API `QueryStorage`](./api-client.md) and its consumer, land on the same data. ## Live cross-tab cache ```typescript const storage = new WorkerCacheStorage({ name: 'live-cache', initialState: initialTodoState, options: { channelName: 'live-cache' }, }) await storage.initialize() // Written in one tab, immediately visible to another tab reading the same channelName. await storage.set('filter', 'active') ``` - Capabilities: `{ shared: true }`. - The cache is **live**: it holds the current state and is shared between tabs, exactly like a MemoryStorage that lives in the SharedWorker. A tab that joins late always receives a fresh snapshot from the worker. - If SharedWorker is unavailable (SSR / tests), the transport falls back to an in-process `Map` — the round-trip is identical, but **without** cross-tab sharing (only a real SharedWorker gives that). > **Need offline or fetch control?** `WorkerCacheStorage` is a **live cross-tab cache**, not an > offline layer — it never touches the network and does not survive a fully closed session. For > real offline / network control, use a separate recipe instead of this storage: > > - [Custom fetch-intercepting ServiceWorker](./custom-fetch-service-worker.md) — your own `sw.js` > transparently intercepts `fetch` (precache, offline routing, cache-first / network-first / > stale-while-revalidate). It lives **below** `ApiClient` and is unrelated to its storage. > - [Custom `baseQuery.fetchFn`](./custom-fetch-fn.md) — replace **how** the client performs a > request (auth-retry, metrics, axios, or a Web Worker transport) while the cache/tags layer keeps > working on top unchanged. ## Tag invalidation (API cache) When `WorkerCacheStorage` backs an API [`QueryStorage`](./api-client.md), the tag index is **not** part of the shared payload — it is **rebuilt on connect** via `rebuildTagIndex`. A second consumer attaching to the same `channelName` scans the existing entries and reconstructs its tag → keys map, so tag-based invalidation keeps working across tabs / instances without shipping the index through the port. ## Pitfalls and limitations 1. **This is a live cache, not persistence.** The state lives in the SharedWorker's memory. It is shared across open tabs and survives a tab reload while the worker stays alive, but it is **not** an offline / session-surviving store. For that, see the recipes linked above. 2. **Values must be structured-clone-able.** Everything that crosses the worker port is subject to the structured-clone algorithm — functions, `Symbol`, `Blob`/`Response`/`Headers` and cyclic structures cannot cross the port. Store plain, clone-compatible data only. 3. **The tag index is rebuilt, not transferred.** As above, `rebuildTagIndex` reconstructs the API tag index on connect rather than sending it through the port — expect a rebuild pass when a new consumer attaches. 4. **SharedWorker fallback is silent-but-degraded.** Without SharedWorker support the transport uses an in-process `Map`: the API is identical but there is no cross-tab sharing. 5. **A broken custom `workerUrl` does not auto-fall-back.** `new SharedWorker(url)` succeeds even if the script 404s or has the wrong MIME type — the failure surfaces asynchronously, after the transport already committed to worker mode. There is no mid-flight fallback: operations then reject by timeout. The channel logs an actionable warning; make sure a custom `workerUrl` is served same-origin as `application/javascript`. Omit `workerUrl` to use the safe inline blob. 6. **Large initial `getAll` may hit the RPC timeout.** Each store RPC has a `requestTimeoutMs` budget (default 1000ms). Priming a very large cache on a slow device can exceed it and fail `initialize()` — raise `options.requestTimeoutMs` in that case. ## When to use - **Live cross-tab cache** (shared state across tabs held in a worker) → `WorkerCacheStorage`. - **Offline / network control** (data available without a network, fetch interception) → not this storage; see [custom fetch-intercepting ServiceWorker](./custom-fetch-service-worker.md) or a [custom `fetchFn`](./custom-fetch-fn.md). - Just cross-tab **notifications** on an existing store → you don't need this at all; use [`sharedWorkerMiddleware`](./shared-worker-middleware.md) / `broadcastMiddleware`. ## Types ```typescript import { WorkerCacheStorage } from 'synapse-storage/core' import type { WorkerStorageConfig, WorkerStorageOptions } from 'synapse-storage/core' interface WorkerStorageOptions { channelName?: string // default: config.name — same channelName ⇒ shared cache workerUrl?: string | URL // custom SharedWorker script (else inline blob) requestTimeoutMs?: number // per-RPC timeout, default 1000 — raise for large initial getAll } // WorkerStorageConfig extends AsyncStorageConfig with options?: WorkerStorageOptions ``` --- # StorageFactory A factory for creating storages — an alternative to calling `new MemoryStorage()` / `new LocalStorage()` / `new IndexedDBStorage()` directly. Handy when the storage type is chosen in a single place or at runtime. The same end-to-end todo domain (`TodoState`, `initialTodoState` — see [MemoryStorage](./memory-storage.md)). ## Typed methods Each method returns a concrete storage type: ```typescript import { StorageFactory } from 'synapse-storage/core' // createMemory -> MemoryStorage (synchronous) const memStorage = StorageFactory.createMemory({ name: 'todo-factory', initialState: initialTodoState, }) // createLocal -> LocalStorage (synchronous) const localStore = StorageFactory.createLocal({ name: 'todo-factory-local', initialState: initialTodoState, }) // createIndexedDB -> IndexedDBStorage (asynchronous) const idbStore = StorageFactory.createIndexedDB({ name: 'todo-factory-idb', initialState: initialTodoState, options: {}, }) await memStorage.initialize() ``` ## Universal create() The type is chosen via the `type` field, and the return type depends on it: ```typescript const sync = StorageFactory.create({ type: 'memory', // -> ISyncStorage name: 'todo-universal-mem', initialState: initialTodoState, }) const sync2 = StorageFactory.create({ type: 'localStorage', // -> ISyncStorage name: 'todo-universal-local', initialState: initialTodoState, }) const async = StorageFactory.create({ type: 'indexedDB', // -> IAsyncStorage name: 'todo-universal-idb', initialState: initialTodoState, options: {}, }) ``` ## When to use - The storage type is chosen in a single place or depends on configuration/environment. - You want a unified style for creating all of the application's stores. ## When not to use - Inside a component — usually [`useCreateStorage`](./hook-memory.md) is more convenient, since it also manages the lifecycle. - If the type is fixed and known — a direct `new`/`.create()` is just as good. Reading, writing, and subscriptions — see the "Working with data" section. --- # useCreateStorage (memory) A React hook that creates and initializes a storage right inside a component and destroys it on unmount. There's no need to keep the store at the module level — its lifecycle matches the component's lifecycle. The same end-to-end todo domain (`TodoState`, `initialTodoState` — see [MemoryStorage](./memory-storage.md)). ## useCreateStorage ```typescript import { useCreateStorage } from 'synapse-storage/react' function TodoApp() { const { storage, isReady, isLoading, hasError, status } = useCreateStorage({ type: 'memory', // 'memory' | 'localStorage' | 'indexedDB' name: 'todo-hook-memory', initialState: initialTodoState, }) // Optional: lifecycle settings as a second argument const result = useCreateStorage( { type: 'memory', name: 'todo-hook-memory', initialState: initialTodoState }, { autoInitialize: true, // auto-initialize (default: true) destroyOnUnmount: true, // destroy on unmount (default: true for memory/local) }, ) // isReady = true -> storage is available (type: ISyncStorage) // isReady = false -> storage = null if (!isReady) return
Loading…
// After isReady the storage is guaranteed to be non-null storage.set('filter', 'active') } ``` ## Reading state — useStorageSubscribe ```typescript import { useStorageSubscribe } from 'synapse-storage/react' // Subscribe to the whole state or to individual fields (re-renders only when the result changes) const state = useStorageSubscribe(storage, (s) => s) const filter = useStorageSubscribe(storage, (s) => s.filter) const activeCount = useStorageSubscribe(storage, (s) => s.todos.filter((t) => !t.done).length) ``` `useStorageSubscribe` accepts `storage | null`, so you can call it before readiness — it returns `undefined`. More details in the [Subscriptions](./subscriptions.md) section. ## When to use - The store is only needed inside a specific component/screen and should disappear with it. - You don't want manual `initialize()` / `destroy()` in `useEffect`. ## When not to use - The store must be global and survive component unmount → create it at the module level (see [MemoryStorage](./memory-storage.md)) or via [createSynapse](./create-synapse-basic.md). --- # useCreateStorage (localStorage) The same [`useCreateStorage`](./hook-memory.md), only with `type: 'localStorage'` — data survives a page reload. The only difference from the memory variant is the `type` field. The same end-to-end todo domain (`TodoState`, `initialTodoState` — see [MemoryStorage](./memory-storage.md)). ## Usage ```typescript import { useCreateStorage, useStorageSubscribe } from 'synapse-storage/react' function TodoApp() { const { storage, isReady } = useCreateStorage({ type: 'localStorage', // <- the only difference from memory name: 'todo-hook-local', initialState: initialTodoState, }) if (!isReady) return
Loading…
// Reading and writing — same as with memory const todos = useStorageSubscribe(storage, (s) => s.todos) storage.set('filter', 'completed') } ``` ## When to use - A component/screen state should survive a reload (draft, selected filter, settings), but you don't want to set up a global module-level store. ## When not to use - The state is ephemeral → [memory variant](./hook-memory.md). - Large data → [IndexedDB variant](./hook-indexeddb.md). More on subscriptions and operations — the "Working with data" section. --- # useCreateStorage (indexedDB) The same [`useCreateStorage`](./hook-memory.md) with `type: 'indexedDB'`. Returns `IAsyncStorage`. Note: `destroyOnUnmount` defaults to `false` for IndexedDB (a persistent storage usually doesn't need to be wiped on unmount). The same end-to-end todo domain (`TodoState`, `initialTodoState` — see [MemoryStorage](./memory-storage.md)). ## Usage ```typescript import { useCreateStorage, useStorageSubscribe } from 'synapse-storage/react' function TodoApp() { const { storage, isReady } = useCreateStorage({ type: 'indexedDB', name: 'todo-hook-idb', initialState: initialTodoState, }) // storage has type IAsyncStorage | null // To destroy the store on unmount — pass the option explicitly: const result = useCreateStorage( { type: 'indexedDB', name: 'todo-hook-idb', initialState: initialTodoState }, { destroyOnUnmount: true }, ) if (!isReady) return
Loading…
// useStorageSubscribe works identically for synchronous and asynchronous storages const todos = useStorageSubscribe(storage, (s) => s.todos) // set/update return a Promise, but await isn't required in handlers storage.update((s) => { s.filter = 'active' }) } ``` ## When to use - A component store needs persistence and/or large amounts of data. ## When not to use - Small state without asynchrony → [localStorage variant](./hook-local-storage.md). - Ephemeral state → [memory variant](./hook-memory.md). More on asynchronous operations — the "Working with data" section. --- # Static .create() Every storage class has a static `.create()` method — a full equivalent of the `new` operator. It's a matter of style: `new MemoryStorage(...)` and `MemoryStorage.create(...)` create identical stores. The same end-to-end todo domain (`TodoState`, `initialTodoState` — see [MemoryStorage](./memory-storage.md)). ## Usage ```typescript import { MemoryStorage, LocalStorage, IndexedDBStorage } from 'synapse-storage/core' // MemoryStorage.create() — equivalent to new MemoryStorage() const memStorage = MemoryStorage.create({ name: 'todo-static', initialState: initialTodoState, }) // LocalStorage.create() — equivalent to new LocalStorage() const localStore = LocalStorage.create({ name: 'todo-static-local', initialState: initialTodoState, }) // IndexedDBStorage.create() — equivalent to new IndexedDBStorage() const idbStore = IndexedDBStorage.create({ name: 'todo-static-idb', initialState: initialTodoState, options: {}, // required for IndexedDB }) await Promise.all([ memStorage.initialize(), localStore.initialize(), idbStore.initialize(), ]) ``` ## new, .create() or StorageFactory? - `new` / `.create()` — when the storage type is known and fixed. Fully equivalent. - [`StorageFactory`](./storage-factory.md) — when the type is chosen in a single place or at runtime. - [`useCreateStorage`](./hook-memory.md) — when the store lives inside a React component. --- # Reading data (get/getState) All the ways to read data from a storage. The examples use the end-to-end `todoStorage` — the same store created in the [MemoryStorage](./memory-storage.md) section: ```typescript import { MemoryStorage } 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({ name: 'todo', initialState: { todos: [], filter: 'all' }, }) await todoStorage.initialize() ``` Synchronous storages (Memory, LocalStorage) return values immediately, while the asynchronous one (IndexedDB) returns a `Promise`, so it needs `await`. ## get(key) — Reading a single field ```typescript // ── Synchronous storage (MemoryStorage / LocalStorage) ── const filter = todoStorage.get('filter') // 'all' const todos = todoStorage.get('todos') // Todo[] const missing = todoStorage.get('xxx') // undefined // ── Asynchronous storage (IndexedDBStorage) ── const filter = await todoStorage.get('filter') const todos = await todoStorage.get('todos') ``` ## getState() — The entire state ```typescript // ── Synchronous storage ── const state = todoStorage.getState() // { todos: [...], filter: 'all' } // ── Asynchronous storage ── const state = await todoStorage.getState() ``` ## getStateSync() — Synchronous read from cache Available on **ALL** storage types — synchronous and asynchronous. Reads from the internal cache, does not touch IndexedDB. Works only after `initialize()`. ```typescript // Synchronous storage — the same as getState() const state = todoStorage.getStateSync() // Asynchronous storage — synchronous access to the cache! const state = asyncStorage.getStateSync() // Useful when you don't want to await, e.g. in render ``` ## has(key) / keys() — Checking and listing ```typescript // ── Synchronous storage ── todoStorage.has('todos') // true todoStorage.has('unknown') // false todoStorage.keys() // ['todos', 'filter'] // ── Asynchronous storage ── await todoStorage.has('todos') // true await todoStorage.keys() // ['todos', 'filter'] ``` --- # Writing data (set/update) All the ways to write data to a storage. The examples use the end-to-end `todoStorage` from the [MemoryStorage](./memory-storage.md) section (`TodoState = { todos: Todo[]; filter: Filter }`). For Memory and LocalStorage writes are synchronous, for IndexedDB they need `await`. ## set(key, value) — Set a value by key ```typescript // ── Synchronous storage (MemoryStorage / LocalStorage) ── todoStorage.set('filter', 'completed') todoStorage.set('todos', [{ id: 't1', title: 'New', done: false }]) // ── Asynchronous storage (IndexedDBStorage) ── await todoStorage.set('filter', 'completed') ``` ## update(updater) — Change several fields at once `update()` uses immer-style mutations. You can mutate the state directly inside the callback. All changes are applied atomically — a single notification to subscribers. ```typescript // ── Synchronous storage ── todoStorage.update((state) => { state.todos.push({ id: 't2', title: 'Buy milk', done: false }) state.filter = 'active' }) // Convenient for a targeted change of a nested element: todoStorage.update((state) => { const target = state.todos.find((t) => t.id === 't2') if (target) target.done = true }) // ── Asynchronous storage ── await todoStorage.update((state) => { state.filter = 'completed' }) ``` ## set() vs update() — When to use which ```typescript // set() — a full replacement of the value at a single key. // Suitable for changing one field or fully replacing an array/object. todoStorage.set('filter', 'active') todoStorage.set('todos', []) // update() — mutating several fields at once. // Suitable for an atomic change of multiple fields. // A single notification to subscribers instead of several. todoStorage.update((s) => { s.todos.push({ id: 't3', title: 'Task', done: false }) s.filter = 'all' }) // With set() each call = a separate notification: todoStorage.set('filter', 'active') // notification 1 todoStorage.set('todos', []) // notification 2 // With update() — a single notification: todoStorage.update((s) => { s.filter = 'active' // notification 1 (combined) s.todos = [] }) ``` ## reset() — Reset to initialState ```typescript // Returns the storage to its initial state (initialState from the config). // Synchronously todoStorage.reset() // Asynchronously await todoStorage.reset() ``` --- # remove / has / keys / clear / reset Operations for checking existence, removing keys, and resetting the storage. The examples use the end-to-end `todoStorage` (`TodoState = { todos: Todo[]; filter: Filter }`). They work the same way for all storage types — for IndexedDB the same methods return a `Promise`. ## has(key) — Check whether a key exists ```typescript // ── Synchronous storage (MemoryStorage / LocalStorage) ── todoStorage.has('todos') // true todoStorage.has('filter') // true todoStorage.has('unknown') // false // ── Asynchronous storage (IndexedDBStorage) ── await todoStorage.has('todos') // true await todoStorage.has('unknown') // false ``` ## keys() — Get all keys ```typescript // ── Synchronously ── const allKeys = todoStorage.keys() // ['todos', 'filter'] // ── Asynchronously ── const allKeys = await todoStorage.keys() ``` ## remove(key) — Remove a specific key ```typescript // Removes a key from the storage. // After removal has(key) returns false, and keys() does not contain that key. // ── Synchronously ── todoStorage.remove('filter') todoStorage.has('filter') // false todoStorage.keys() // ['todos'] // ── Asynchronously ── await todoStorage.remove('filter') ``` ## clear() — Clear the storage ```typescript // Removes ALL keys. The state becomes an empty object {}. // ── Synchronously ── todoStorage.clear() todoStorage.getState() // {} todoStorage.keys() // [] // ── Asynchronously ── await todoStorage.clear() ``` ## reset() — Reset to initialState ```typescript // Returns the state to its initial value (initialState from the config). // ── Synchronously ── todoStorage.reset() todoStorage.getState() // { todos: [...], filter: 'all' } // ── Asynchronously ── await todoStorage.reset() ``` ## clear() vs reset() — What's the difference ```typescript const todoStorage = new MemoryStorage({ name: 'todo', initialState: { todos: [], filter: 'all' }, }) todoStorage.set('filter', 'completed') // clear() — a full wipe todoStorage.clear() todoStorage.getState() // {} todoStorage.keys() // [] // reset() — back to initialState todoStorage.reset() todoStorage.getState() // { todos: [], filter: 'all' } todoStorage.keys() // ['todos', 'filter'] ``` --- # Subscriptions (subscribe) All the ways to subscribe to data changes in a storage. The examples use the end-to-end `todoStorage` (`TodoState = { todos: Todo[]; filter: Filter }`). They work the same way for Memory, LocalStorage and IndexedDB. ## 1. subscribe(key, callback) Subscribing to a specific top-level key. The callback is called on every change of that key. ```typescript const unsub = todoStorage.subscribe('filter', (newFilter) => { console.log('filter changed:', newFilter) // 'all' | 'active' | 'completed' }) const unsub2 = todoStorage.subscribe('todos', (newTodos) => { console.log('list changed:', newTodos) // Todo[] }) // Unsubscribe unsub() ``` ## 2. subscribe(selector, callback) Subscribing via a selector function. The callback is called when the selector's result changes. ```typescript // Computed value — the number of active tasks const unsub = todoStorage.subscribe( (state) => state.todos.filter((t) => !t.done).length, (activeCount) => console.log('active tasks:', activeCount) ) // Subscribing to a single field const unsub2 = todoStorage.subscribe( (state) => state.filter, (filter) => console.log('filter:', filter) ) unsub() ``` ## 3. subscribeToAll(callback) Subscribing to ALL storage changes. The callback receives an event with information about the change. ```typescript const unsub = todoStorage.subscribeToAll((event) => { console.log(event.type) // 'set' | 'update' | 'remove' | 'clear' | 'reset' console.log(event.key) // a key or an array of keys console.log(event.changedPaths) // paths to the changed fields }) unsub() ``` ## 4. useStorageSubscribe (React hook) ```typescript import { useStorageSubscribe } from 'synapse-storage/react' function TodoStats({ storage }: { storage: ISyncStorage }) { // Subscribing to a single field const filter = useStorageSubscribe(storage, (s) => s.filter) // Computed value — re-render only when the result changes const total = useStorageSubscribe(storage, (s) => s.todos.length) const active = useStorageSubscribe(storage, (s) => s.todos.filter((t) => !t.done).length) return
{filter}: {active} active of {total}
} ``` Pass `equals` to skip re-renders when an object/array slice is referentially unchanged: ```typescript const todos = useStorageSubscribe(storage, (s) => s.todos, { equals: (a, b) => a === b }) ``` See **[Reactive reads & controlled re-renders](./reactive-reads.md)** for `useStorageObservable` (RxJS path) and `useStorageRef` (read without re-rendering / manual trigger). --- # Reactive reads & controlled re-renders The everyday pattern: you mutate a storage with ordinary methods (`set`/`update`) and read it **reactively** inside a component. Synapse gives you several hooks for this — the difference between them is **how much control you get over re-renders** and whether you need RxJS. This is an overview page: pick the right tool from the table, the details live on dedicated pages. The examples use the end-to-end `todoStorage` (`TodoState = { todos: Todo[]; filter: Filter }`). ## Which tool when | Tool | Re-renders | RxJS | Use when | Page | |------|-----------|------|----------|------| | `useStorageSubscribe` | on every slice change | no | default reactive read | [useStorageSubscribe](./use-storage-subscribe.md) | | `useSelector` | on every slice change | no | reading a `SelectorAPI` | [Selectors](./selector-system.md) | | `useStorageObservable` | on every slice change | yes | you need RxJS operators | [useStorageObservable](./use-storage-observable.md) | | `toObservable` | — (outside React) | yes | effects and non-React code | [toObservable](./to-observable.md) | | `getStateSync()` | **none** | no | read the latest on demand in a handler | see below | ## Reading without a re-render is not a hook A common case is "read the current value at the moment of a click/submit without re-rendering the component on every store change". You **don't need a dedicated hook** for that: a storage is read synchronously on demand via `getStateSync()`. ```typescript // zero subscriptions, zero re-renders — the fresh value at call time const onSave = () => { const { todos } = todoStorage.getStateSync() api.save(todos) } ``` If you want a re-render only when a specific slice changes, that's `useStorageSubscribe` with `equals` (Concurrent-safe), not a manual force. If you need operators (`debounceTime`, `scan`, …), that's `useStorageObservable` / `toObservable`. There is deliberately no "ref hook with a manual re-render" in the API: all three scenarios are covered by the tools above. --- # useStorageSubscribe The **default** way to read a storage reactively. `useSyncExternalStore` under the hood (Concurrent-safe), no RxJS. Re-renders the component when the selected slice changes. See also the [Reactive reads](./reactive-reads.md) overview. The examples use the end-to-end `todoStorage` (`TodoState = { todos: Todo[]; filter: Filter }`). ## Basic usage For **primitive** slices it de-dupes automatically via `Object.is` — no needless re-renders. ```typescript import { useStorageSubscribe } from 'synapse-storage/react' // primitive slice — de-duped automatically const filter = useStorageSubscribe(todoStorage, (s) => s.filter) ``` ## Object and array slices: `equals` When the selector returns an object/array (a new reference every tick) or you want to re-render only when a specific slice changes, pass `equals`. It keeps a stable snapshot and skips needless re-renders even when the rest of the store changes. ```typescript // an unrelated store change won't re-render the component until `todos` changes by reference const todos = useStorageSubscribe(todoStorage, (s) => s.todos, { equals: (a, b) => a === b, }) ``` `equals` returns `true` when the slices are "equal" — then the snapshot keeps its reference and there is no re-render. ## Notes - `useSyncExternalStore` gives correct behavior in Concurrent Mode (no tearing). - Accepts `IStorageBase` — the shared interface of sync and async storages; the subscription is the same for all types. - Before init you can pass `null` instead of the storage — the hook returns `undefined`. - Need RxJS operators on top of the stream? That's [useStorageObservable](./use-storage-observable.md). Reading a `SelectorAPI`? That's [useSelector](./selector-system.md). Need to read without a re-render in a handler? `todoStorage.getStateSync()` — see the [overview](./reactive-reads.md). --- # useStorageObservable The RxJS path for "store → reactive in a component". Equivalent to [useStorageSubscribe](./use-storage-subscribe.md), but you can pipe RxJS operators (`debounceTime`, `scan`, `bufferTime`, …) on top of the state stream. See the [Reactive reads](./reactive-reads.md) overview. The examples use the end-to-end `todoStorage` (`TodoState = { todos: Todo[]; filter: Filter }`). ## Basic usage ```typescript import { useStorageObservable } from 'synapse-storage/react' // whole state const state = useStorageObservable(todoStorage) // a slice — emits only when the slice changes (distinctUntilChanged) const total = useStorageObservable(todoStorage, (s) => s.todos.length) ``` Internally it's a memoizing wrapper over [`toObservable`](./to-observable.md) + `useObservable`. The observable is memoized by `[storage]`, so the hook does **not** re-subscribe on every render. That's enough when you just need a slice. But you **can't** attach your own operators through `useStorageObservable` — for that drop one level down: `toObservable` (builds the stream) + `useObservable` (subscribes and returns the value to render). ## Operators on top of the stream `toObservable(storage, selector)` gives an `Observable` you can pipe any RxJS operators onto. To keep the subscription stable (instead of recreating it every render), wrap building the stream in a **factory** and pass it to `useObservable` — it subscribes in `useEffect` and returns the latest value. ```tsx import { useObservable } from 'synapse-storage/react' import { toObservable } from 'synapse-storage/reactive' import { debounceTime, map } from 'rxjs/operators' function TodoBadge() { // todoStorage is a module-level singleton, stable ref → deps can be omitted const label = useObservable( () => toObservable(todoStorage, (s) => s.todos.length).pipe( debounceTime(200), map((count) => `${count} todos`), ), '0 todos', ) // label is a plain string, render it as is return
{label}
} ``` `useObservable` returns a **ready value** (a string here) you can drop straight into JSX. Until the first emit it shows `initialValue` (`'0 todos'`). The stream emits the initial value on subscribe, so the badge doesn't flash empty. ## Why `debounce` here Without operators the badge would recompute on **every** `todos` change. With `debounceTime(200)` — if a burst of changes arrives within 200 ms (bulk add, import), the component updates **once** with the final value instead of 10 times in a row. That's the point of the RxJS path: smooth the stream before it reaches render. ## About `deps` — what goes in The third argument of `useObservable` is the dependency array for re-subscription. Rule of thumb: **everything the factory closes over that can change goes into `deps`**. - **Singleton store** (module constant) — stable ref, nothing to re-subscribe to. `deps` can be **omitted** (the factory default is `[]`, the subscription is built once on mount). `[todoStorage]` is also correct here, just redundant. - **Store from props / context / `useCreateStorage`** — the ref can change. Then `[storage]` is **required**, otherwise the stream stays subscribed to the old instance (stale). - **The factory closes over external values** (a `limit` prop, a selected `userId`, etc.) — put them in `deps`, otherwise the chain won't rebuild when they change and will run with the stale closure. ```tsx // store from props + external limit prop used inside pipe → both in deps const recent = useObservable( () => toObservable(store, (s) => s.items).pipe( map((items) => items.slice(0, limit)), ), [], [store, limit], ) ``` ## Example: debounced search The live input value and the "heavy" search result are **two different** reactive reads. The input value must update instantly (a plain subscription), while filtering should only run once the user stops typing (a debounced stream): ```tsx import { useStorageSubscribe, useObservable } from 'synapse-storage/react' import { toObservable } from 'synapse-storage/reactive' import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators' function SearchBox() { // 1) live input value — updates on every keystroke const query = useStorageSubscribe(searchStorage, (s) => s.query) // 2) results — recomputed only after the user idles for 300 ms const matches = useObservable( () => toObservable(searchStorage, (s) => s.query).pipe( map((q) => q.trim().toLowerCase()), debounceTime(300), distinctUntilChanged(), map((q) => (q ? filterProducts(q) : [])), ), [], ) return (
searchStorage.update((s) => { s.query = e.target.value }) } />
    {matches.map((p) => (
  • {p.title}
  • ))}
) } ``` The input doesn't lag (the value from `useStorageSubscribe` is immediate), and `filterProducts` runs once every 300 ms after a pause instead of on every keystroke. ## Example: a notification aggregator The classic case: 10 messages arrive within a couple of seconds — you want to show **one** notification "10 new messages", not 10 toasts. That's a side-effect (call `toast.show`), not a render value, so we use `useSubscription` instead of `useObservable` — it subscribes and renders **nothing**. Model: `messagesStorage` holds `{ inbox: Message[] }`, each new message is pushed into `inbox`. ```tsx import { useSubscription } from 'synapse-storage/react' import { toObservable } from 'synapse-storage/reactive' import { bufferTime, filter, map, pairwise } from 'rxjs/operators' function MessageNotifier() { useSubscription( () => toObservable(messagesStorage, (s) => s.inbox.length) .pipe( pairwise(), // [was, now] map(([prev, next]) => next - prev), // how many were added filter((added) => added > 0), // arrivals only (not removals) bufferTime(2000), // collect events for 2 seconds filter((batch) => batch.length > 0), // skip empty windows map((batch) => batch.reduce((sum, n) => sum + n, 0)), // total per window ) .subscribe((count) => { toast.show(count === 1 ? 'New message' : `${count} new messages`) }), [], ) return null } ``` How it reads: - `pairwise` + `map` turn "inbox length" into "how many were added this tick"; - `bufferTime(2000)` collects those arrivals in 2-second windows; - per window — one `toast.show` with the total. So a burst of 10 messages within 2 seconds yields **one** toast "10 new messages". That's the answer to "can the hook do this": once you're inside an `Observable`, the whole RxJS toolbox is available — you just pick the right entry point (`useObservable` to render a value, `useSubscription` to run a side-effect). ## Notes - A `toObservable` selector stream already runs through `distinctUntilChanged` — an extra `distinctUntilChanged` right after the selector is almost always redundant. - Need a simple reactive read without RxJS? Use [useStorageSubscribe](./use-storage-subscribe.md). - A stream outside React (effects, non-React code) — [toObservable](./to-observable.md). --- # toObservable Turns a storage (`IStorageBase`) into an RxJS `Observable` of the state stream — for **effects and non-React code**. It's the low-level utility that [useStorageObservable](./use-storage-observable.md) is built on. Imported from `synapse-storage/reactive`. The examples use the end-to-end `todoStorage` (`TodoState = { todos: Todo[]; filter: Filter }`). ## Signature ```typescript // whole state toObservable(storage: IStorageBase): Observable // slice + optional comparator toObservable( storage: IStorageBase, selector: (state: T) => R, equals?: (a: R, b: R) => boolean, ): Observable ``` Three parameters: 1. **`storage`** — the storage. 2. **`selector`** *(optional)* — which slice to pull out of the state. 3. **`equals`** *(optional)* — how to compare adjacent slice values to skip duplicates. ## `selector` — a slice instead of the whole state Without a selector the stream emits the **whole** state on **every** store change — even when a field you don't care about changed. With a selector the stream `map`s the state to a slice and runs it through `distinctUntilChanged`, so it emits **only when the slice actually changed**: ```typescript import { toObservable } from 'synapse-storage/reactive' const state$ = toObservable(todoStorage) // Observable, on any change const count$ = toObservable(todoStorage, (s) => s.todos.length) // Observable, only when it changes ``` Here `count$` won't fire if `filter` changed — the `todos` length is the same. That's the optimization: the subscriber (component/effect) doesn't wake up on unrelated changes. ## `equals` — how slices are compared The third parameter is the comparator for `distinctUntilChanged`. It decides whether a new slice value is "the same" (return `true` → the emission is skipped). By default the comparison uses `Object.is`. `Object.is` is enough for **primitives** (`number`, `string`, `boolean`) and for slices with a **stable reference** (when immutable updates don't touch that object — the reference is preserved). A custom `equals` is needed in two cases: **1. The selector returns a new object/array every time.** Then `Object.is` sees a new reference on every tick and `distinctUntilChanged` won't de-dupe — the stream emits on every store change. Pass a by-content comparison: ```typescript // factory selector: a new array each time → needs a by-value equals const ids$ = toObservable( todoStorage, (s) => s.todos.map((t) => t.id), (a, b) => a.length === b.length && a.every((id, i) => id === b[i]), ) ``` **2. You need a coarser equivalence than identity.** For example, emit only when a property of the value changes, not the value itself: ```typescript // emits only when the parity of the count changes (1→3 stays quiet, 3→4 emits) const parity$ = toObservable(todoStorage, (s) => s.todos.length, (a, b) => a % 2 === b % 2) ``` > `equals` only makes sense together with `selector` — without a slice there's nothing to compare. ## In effects A typical case is wiring one storage's state as an external state into `createEffectConfig`: ```typescript const auth$ = toObservable(authStorage, (s) => s.user.id) createEffectConfig: () => ({ externalStates: { userId: auth$ }, }) ``` ## Notes - The stream emits the current state immediately on subscribe (via `getStateSync()`), then on every change. - Under the hood it's `shareReplay({ refCount: true })`: multiple subscribers share a single store subscription, and when their count drops to zero the stream unsubscribes from the storage (no leaked listeners). - In a React component, do **not** create `toObservable(...)` directly in render — memoize it (which is what [useStorageObservable](./use-storage-observable.md) does) or subscribe via `useObservable` with a factory. - For a simple reactive read in a component without RxJS, use [useStorageSubscribe](./use-storage-subscribe.md). --- # useSubscription An imperative **side-effect** subscription from a component: subscribe to an `Observable` and do something on each emit (show a toast, log, dispatch) **without returning anything to render**. It's the counterpart to [useObservable](./use-storage-observable.md): that one returns a value for JSX, while `useSubscription` is for effects. See the [Reactive reads](./reactive-reads.md) overview. ## Signature ```typescript useSubscription(factory: () => Unsubscribable, deps: DependencyList): void ``` - `factory` — creates the subscription (`source$.subscribe(...)`); its side-effects live inside the `subscribe` callback. - The returned `Unsubscribable` is **torn down automatically** on unmount and on `deps` change (before creating a new subscription) — no manual unsubscribe needed. - Renders nothing and returns nothing. ## Basic usage ```tsx import { useSubscription } from 'synapse-storage/react' import { toObservable } from 'synapse-storage/reactive' import { filter } from 'rxjs/operators' function ErrorToaster() { useSubscription( () => toObservable(authStorage, (s) => s.error) .pipe(filter((err): err is string => Boolean(err))) .subscribe((message) => { toast.error(message) }), [], ) return null } ``` The subscription lives exactly as long as the component is mounted: on unmount `useSubscription` calls `unsubscribe()` for you. ## When `useSubscription` vs `useObservable` | You need | Hook | |----------|------| | A **value** to render (a slice, a debounced result) | [`useObservable`](./use-storage-observable.md) | | A **side-effect** on each emit (toast, log, imperative call) | `useSubscription` | Simple rule: if the result goes into JSX — `useObservable`; if it's "do something outward" — `useSubscription`. Don't hand-roll the same thing in `useEffect` — `useSubscription` already encapsulates creation and guaranteed teardown. ## Example: a notification aggregator The classic case — collapse a burst of events into one notification (10 messages in a couple of seconds → one toast "10 new messages"). That's a side-effect, hence `useSubscription`: ```tsx import { useSubscription } from 'synapse-storage/react' import { toObservable } from 'synapse-storage/reactive' import { bufferTime, filter, map, pairwise } from 'rxjs/operators' function MessageNotifier() { useSubscription( () => toObservable(messagesStorage, (s) => s.inbox.length) .pipe( pairwise(), // [was, now] map(([prev, next]) => next - prev), // how many were added filter((added) => added > 0), // arrivals only bufferTime(2000), // collect for 2 seconds filter((batch) => batch.length > 0), // skip empty windows map((batch) => batch.reduce((sum, n) => sum + n, 0)), // total per window ) .subscribe((count) => { toast.show(count === 1 ? 'New message' : `${count} new messages`) }), [], ) return null } ``` A detailed walkthrough of the operators lives on the [useStorageObservable](./use-storage-observable.md#example-a-notification-aggregator) page. ## About `deps` Same rules as [useObservable](./use-storage-observable.md#about-deps--what-goes-in): `deps` holds everything the factory closes over that can change. For a singleton store `[]` is enough; for a store from props/context use `[storage]`, otherwise the subscription stays on the old instance. ## Teardown and memory `useSubscription` tears the subscription down automatically (a `useEffect` cleanup), and `toObservable` uses `shareReplay({ refCount: true })` under the hood — when the subscriber count drops to zero it unsubscribes from the store. So sprinkling `useSubscription`/`useObservable` across the project does **not** accumulate listeners on the storage: everything is released on unmount. --- # 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](./memory-storage.md) section and its canonical `TodoSelectors` set. ## 1. The Selectors class ```typescript 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({ name: 'todo', initialState: { todos: [], filter: 'all' }, }) await todoStorage.initialize() // The class is bound to the storage through the constructor. class TodoSelectors extends Selectors { readonly todos = this.select((s) => s.todos) } const selectors = new TodoSelectors(todoStorage) ``` ## 2. this.select — simple ```typescript const filterTodos = (todos: Todo[], filter: Filter) => filter === 'all' ? todos : todos.filter((t) => (filter === 'active' ? !t.done : t.done)) class TodoSelectors extends Selectors { 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. ```typescript class TodoSelectors extends Selectors { 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 ```typescript class TodoSelectors extends Selectors { // 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 id ``` ### Cross-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. ```typescript import type { IStorage, SelectorAPI } from 'synapse-storage/core' class PostsSelectors extends Selectors { readonly list = this.select((s) => s.list) // cross-store: recomputed reactively when the other store changes readonly currentUserId: SelectorAPI constructor(storage: IStorage, 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](./dependencies.md). > **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()` returns `true` only after both > the `PostsState` store and the `core` store are ready. The same aggregation applies to > `onSourceStatusChange`. A simple selector (`this.select`) is bound to its single > source. ## 4. Reactive selector (selector.$) Every selector has a `.$` field — an `Observable`. 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 ```typescript 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: ```typescript class SearchEffects extends Effects { 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 ```typescript 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
active (debounced): {debouncedActive}
} ``` ## 5. useSelector — React hook (current value) ```typescript 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
Loading...
return
{visible?.map((t) =>
{t.title}
)}
} ``` ## 6. Programmatic access to a selector ```typescript // 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() ``` --- # createSynapse (basic) `createSynapse(factory)` assembles the **data-management layer** into a single lazy module. 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](./create-synapse-dispatcher.md), [Effects](./create-synapse-effects.md)). Everything on one domain — `pokemon-advanced` (see the [Pokemon example](./pokemon-advanced.md)). Here we take exactly two bricks from it: `pokemon.store.ts` and `pokemon.selectors.ts`. ## Storage and state (`pokemon.store.ts`) ```typescript 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 `SelectorAPI`s 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`. ```typescript import { Selectors } from 'synapse-storage/core' import type { PokemonState } from './pokemon.types' export class PokemonSelectors extends Selectors { // 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 > in `pokemon.selectors.ts`. More on selectors themselves — [Selectors](./selector-system.md). ## Assembly: createSynapse(factory) `createSynapse(factory)` returns a **lazy handle**. The factory runs once — on the first `await` / `ready()`, not on import (this matters for SSR and for keeping a module import from hitting the network). The minimal form — storage + selectors only: ```typescript 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' const pokemonSynapse = createSynapse(async () => { const storage = new MemoryStorage({ name: 'pokemon-advanced', initialState }) return { storage, selectors: new PokemonSelectors(storage), // dispatcher / effects — we'll add them on the next pages } }) export type PokemonSynapse = Awaited ``` ## The return value ```typescript // The handle is thenable: await runs the factory and returns the assembled module const store = await pokemonSynapse // The result (basic — no dispatcher): store.storage // IStorage — the storage store.selectors // a PokemonSelectors instance — fields = SelectorAPI store.state$ // Observable — the state stream (ALWAYS present, even without effects) store.dispatcher // undefined (no dispatcher) store.actions // undefined (the dispatcher alias) // The handle itself: pokemonSynapse.ready() // Promise — same as await pokemonSynapse.isReady() // boolean pokemonSynapse.getSnapshot() // store | undefined — synchronous access (needed for SSR) pokemonSynapse.destroy() // Promise — cleanup + memoization reset (the handle is recreatable) ``` ## Usage in React Without a dispatcher we read through `useSelector` and write through storage **directly**: ```typescript 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/update` is fine for simple state. As soon as named intents and side-effects > (loading from an API) appear — that's the job of [Dispatcher](./create-synapse-dispatcher.md) and > [Effects](./create-synapse-effects.md). ## Async initialization in the factory The factory is a plain `async` function, so any prologue (fetching seed data, an API client's `init()`) is done right inside it, before assembling the module: ```typescript const pokemonSynapse = createSynapse(async () => { // the async prologue runs once on the first await const seed = await fetch('https://pokeapi.co/api/v2/pokemon?limit=12').then((r) => r.json()) const storage = new MemoryStorage({ name: 'pokemon-advanced', initialState: { ...initialState, /* ...prepared seed... */ }, }) return { storage, selectors: new PokemonSelectors(storage), } }) ``` > In the full module this prologue is `await initPokemonApi()` (initializing `pokemonApiClient`). How > it looks together with the dispatcher, effects, and dependencies — [Pokemon example](./pokemon-advanced.md). --- # createSynapse (dispatcher) The next brick after the [basic assembly](./create-synapse-basic.md): we add the **dispatcher**. It describes **intents** — named actions that change state — and **watchers** for reactive tracking. Effects (API calls per action) are on the [next page](./create-synapse-effects.md). Same domain — `pokemon-advanced`. ## Dispatcher (`pokemon.dispatcher.ts`) Actions and watchers are declared as **class fields**, the action name = the field name. The assembler finalizes the dispatcher (generates `actionType` from the field names) before start. ```typescript import { Dispatcher } from 'synapse-storage/reactive' import type { PokemonBrief, PokemonDetails, PokemonState } from './pokemon.types' export class PokemonDispatcher extends Dispatcher { // apiActions — a callable request-lifecycle group (see dispatcher-detailed) readonly loadList = this.apiActions((s) => s.api.listRequest) readonly loadDetails = this.apiActions((s) => s.api.detailsRequest) // signal — a pure intent signal with no state write (an effect handles it) readonly loadMore = this.signal('Load the next page') // action — an intent that changes state itself readonly selectPokemon = this.action((store, id: number | null) => { store.update((s) => { s.selectedPokemonId = id if (id === null) s.selectedPokemon = null }) return id }) readonly setSearchQuery = this.action((store, query: string) => { store.set('searchQuery', query) return query }) 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 }) // Actions an effect uses to write the request result into state readonly applyPokemonList = this.action((store, data: { list: PokemonBrief[]; hasMore: boolean; append: boolean }) => store.update((s) => { s.pokemonList = data.append ? [...s.pokemonList, ...data.list] : data.list s.offset = s.pokemonList.length s.hasMore = data.hasMore }), ) readonly applyPokemonDetails = this.action((store, details: PokemonDetails) => store.update((s) => { s.selectedPokemon = details }), ) // watcher — reactively tracks a slice of state readonly watchFavoriteCount = this.watcher({ selector: (s) => s.favorites.length, meta: { description: 'tracking the favorites count' }, notifyAfterSubscribe: true, }) } ``` ## this.action `this.action((store, params) => result)` — a handler in the "recipe" signature. **The action's payload = the handler's return value** (that's why `selectPokemon` returns `id`, and `toggleFavorite` returns `id`: their payload is later caught by effects). ```typescript // Call through store.actions (action name = field name) store.actions.selectPokemon(25) store.actions.setSearchQuery('pika') store.actions.toggleFavorite(25) // actionType is generated from the field name at finalization store.actions.selectPokemon.actionType // '[pokemon-advanced]selectPokemon' ``` > `store.actions.X` is shorthand for `store.dispatcher.dispatch.X`. ## this.watcher `this.watcher` reactively tracks state changes and returns an RxJS `Observable`: ```typescript class PokemonDispatcher extends Dispatcher { readonly watchFavoriteCount = this.watcher({ selector: (state) => state.favorites.length, // what to track notifyAfterSubscribe: true, // fire immediately on subscribe shouldTrigger: (prev, curr) => prev !== curr, // filter (optional) }) } // Subscribe — through the watchers registry (calling the factory → Observable) const sub = store.dispatcher.watchers.watchFavoriteCount().subscribe((action) => { console.log('favorites:', action.payload) }) sub.unsubscribe() ``` ## signal and apiActions `this.signal(description?)` — a pure signal `(_store, payload) => payload`: writes nothing to state, only throws an intent into the stream (an effect picks it up — like `loadMore`). `this.apiActions(accessor)` — a callable request-lifecycle **group**. The call itself (`loadList()`) = init (status `idle`), while `.loading()` / `.success()` / `.failure(error)` / `.reset()` write the status at the given state path. The full surface and the `ofType` rule — [Dispatcher (detailed)](./dispatcher-detailed.md). ## Assembly ```typescript import { MemoryStorage } from 'synapse-storage/core' import { createSynapse } from 'synapse-storage/utils' import { PokemonDispatcher } from './pokemon.dispatcher' import { PokemonSelectors } from './pokemon.selectors' import { initialState } from './pokemon.store' import type { PokemonState } from './pokemon.types' const pokemonSynapse = createSynapse(async () => { const storage = new MemoryStorage({ name: 'pokemon-advanced', initialState }) return { storage, dispatcher: new PokemonDispatcher(storage), selectors: new PokemonSelectors(storage), // effects — on the next page } }) ``` ## The return value ```typescript const store = await pokemonSynapse store.storage // IStorage store.selectors // a PokemonSelectors instance store.dispatcher // a PokemonDispatcher instance (dispatch, watchers, action$) store.actions // an alias of store.dispatcher.dispatch: { selectPokemon, setSearchQuery, ... } // store.actions.selectPokemon === store.dispatcher.dispatch.selectPokemon // The stream of all actions (an RxJS Observable) — effects are built on it store.dispatcher.actions.subscribe((action) => { console.log(action.type, action.payload) }) ``` ## React (createSynapseCtx) ```typescript import { createSynapseCtx } from 'synapse-storage/react' // Pass the handle ITSELF (not a call) — the factory starts lazily on the Provider's first mount export const { contextSynapse, useSynapseSelectors, useSynapseActions } = createSynapseCtx(pokemonSynapse, { loadingComponent:
Loading...
}) ``` More — [createSynapseCtx](./synapse-ctx.md). How intents turn into real API calls — [Effects](./create-synapse-effects.md). --- # createSynapse (effects) The last brick after the [dispatcher](./create-synapse-dispatcher.md): **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`. ## Effects (`pokemon.effects.ts`) Effects are a class over `Effects`. 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. ```typescript 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 { constructor( private readonly api: PokemonApiEndpoints, private readonly settings$: Observable, ) { 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. ```typescript 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 shared `action$`), available if a third generic is declared: `class Effects`. > **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 inside `this.effect`, not values > computed in place. Optional teardown — `override onDestroy()` (close sockets, unsubscribe from an external source). ## ofType / ofTypes ```typescript 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)](./dispatcher-detailed.md). ## 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: ```typescript // 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`: ```typescript 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`. - **`validator`** returns `{ conditions, skipAction }`: `conditions` is an array of boolean gates (all must be `true`), otherwise `skipAction` is 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 the `loading` status (via the dispatcher's `apiActions` group). - **`apiCall`** receives the pipe's value, calls `fromRequest(this.api.X.request(...))`, and inside `apiResult` writes the result (`d.applyPokemon...`) + `d.X.success()`. - **`errorAction`** catches `ApiError` → `d.X.failure(...)`. ```typescript 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) before `apiCall`; its result arrives as the second argument of `apiCall`. No `prepare` → `body` is `undefined`. > **Why not `validateMap` for writes?** `validateMap` is locked to `switchMap`, 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. ```typescript 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](https://github.com/Vlad92msk/synapse/blob/master/packages/examples/src/examples/CreateSynapseEffectsExample.tsx) > with `debounceTime` + `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)`). ```typescript 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(async () => { await initPokemonApi() // async prologue: initialize the API client const storage = new MemoryStorage({ name: 'pokemon-advanced', initialState }) return { storage, dependencies: [settingsStorage], // dependency on another storage dependencyTimeout: 10000, dispatcher: new PokemonDispatcher(storage), selectors: new PokemonSelectors(storage), // services and external stores — through the effects' constructor (captured in the closure) effects: new PokemonEffects(pokemonApiClient.getEndpoints(), toObservable(settingsStorage)), } }) ``` Effects start **automatically** when the module is initialized (the first `await pokemonSynapse`). More on the async factory, `dependencies` and `dependencyTimeout` — [Dependencies](./dependencies.md). ## Return value ```typescript const store = await pokemonSynapse store.storage // IStorage store.selectors // a PokemonSelectors instance store.dispatcher // a PokemonDispatcher instance store.actions // { loadList, selectPokemon, ... } (alias of store.dispatcher.dispatch) store.state$ // Observable — 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 → applyPokemonDetails ``` How to hand the assembled `pokemonSynapse` to React components — [createSynapseCtx](./synapse-ctx.md). The full module — [Pokemon (recipe)](./pokemon-advanced.md). --- # Dispatcher (in detail) The full surface of the `Dispatcher` class. The [assembly page](./create-synapse-dispatcher.md) 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. ```typescript 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({ name: 'pokemon-advanced', initialState }) await storage.initialize() const dispatcher = new PokemonDispatcher(storage) dispatcher.selectPokemon(25) // actions — typed instance fields ``` ## Dispatcher surface | Factory field | What it creates | |-------------------------------------|------------------------------------------------------------------------------| | `this.action(fn)` | an action with a handler `(store, params) => result`; payload = the returned value | | `this.signal

(desc)` | a pure intent signal: `(_store, p) => p`, writes nothing to the store | | `this.apiActions

(accessor)` | a callable group for an API request lifecycle | | `this.keyedApiActions

(accessor)` | the same, but status is stored per key (`Record`) | | `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.** ```typescript class PokemonDispatcher extends Dispatcher { // 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.*`). ```typescript class PokemonDispatcher extends Dispatcher { readonly loadMore = this.signal('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. ```typescript class PokemonDispatcher extends Dispatcher { readonly loadList = this.apiActions((s) => s.api.listRequest) readonly loadDetails = this.apiActions((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 → reset ``` In 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](./create-synapse-effects.md). ### Rule: `ofType(d.loadList)` catches ONLY init ```typescript // 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`), 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`): ```typescript // hypothetical cell: api.detailsByIdRequest: Record 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`). ```typescript class PokemonDispatcher extends Dispatcher { // 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 ```typescript // 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 `createSynapse` assembly the dispatcher is available as `store.dispatcher`, and `store.actions` > is an alias of `store.dispatcher.dispatch`. See [createSynapse (dispatcher)](./create-synapse-dispatcher.md). --- # Cross-module dependencies One `createSynapse` can depend on another storage or module. Dependencies are **awaited before the factory runs** — by assembly time they are guaranteed to be initialized. 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: ```typescript // pokemon.settings.ts import { MemoryStorage } from 'synapse-storage/core' export interface PokemonSettings { pageSize: number } export const settingsStorage = new MemoryStorage({ name: 'pokemon-settings', initialState: { pageSize: 12 }, }) ``` The pokemon module declares it in `dependencies` and folds `settings$` into the effects: ```typescript // 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(async () => { await initPokemonApi() // the factory's async prologue const storage = new MemoryStorage({ name: 'pokemon-advanced', initialState }) return { storage, dependencies: [settingsStorage], // wait for readiness before assembly dependencyTimeout: 10000, // ms, default 30000 dispatcher: new PokemonDispatcher(storage), selectors: new PokemonSelectors(storage), // settings$ — the external store's state as an Observable (pattern 1, see below) effects: new PokemonEffects(pokemonApiClient.getEndpoints(), toObservable(settingsStorage)), } }) ``` **A dependency can be** (`DependencyInput`): - a raw `IStorage` — like `settingsStorage` above (its `initialize()` is awaited for us); - another synapse handle — `dependencies: [await otherSynapse]` (the handle is thenable + `waitForReady`); - any `PromiseLike<{ storage }>`. In the effects `pageSize` arrives via `withLatestFrom(this.settings$)` — see [Effects](./create-synapse-effects.md). 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](https://github.com/Vlad92msk/synapse/blob/master/packages/examples/src/examples/DependenciesExample.tsx). ### 1. Read another store's STATE in effects — via `toObservable` Exactly what pokemon does with the settings: ```typescript import { toObservable } from 'synapse-storage/reactive' class PokemonEffects extends Effects { constructor(private readonly api: PokemonApiEndpoints, private readonly settings$: Observable) { 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): ```typescript import type { IStorage, SelectorAPI } from 'synapse-storage/core' class SettingsSelectors extends Selectors { theme = this.select((s) => s.theme) currentUserId: SelectorAPI constructor(storage: IStorage, private auth: AuthSynapse['selectors']) { super(storage) // depends on ANOTHER store's selector → recomputes reactively this.currentUserId = this.combine([this.auth.userId], (userId) => userId) } } // assembly (the factory awaited auth and passed in its selectors): const auth = await authSynapse return { storage, dependencies: [auth], selectors: new SettingsSelectors(storage, auth.selectors), } ``` ### 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$`): ```typescript class SettingsEffects extends Effects { 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 return { storage, dependencies: [auth], dispatcher: new SettingsDispatcher(storage), effects: new SettingsEffects(), externalDispatchers: { auth: auth.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](./event-bus.md). ## Initialization order ```typescript // The order inside a createSynapse factory: // 1. Dependencies are ready (Promise.all + timeout); their storage.initialize() is idempotent // 2. The factory runs → creates storage, dispatcher, selectors, effects // 3. storage.initialize() + starting 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](./synapse-ctx.md) and [awaitSynapse](./await-synapse.md). The full module — [Pokemon (recipe)](./pokemon-advanced.md). --- # createSynapseCtx React Context + HOC for accessing a Synapse module through hooks. A lazy handle is passed in: the factory starts on the first mount of the Provider (not on import), with an automatic `loadingComponent` during initialization. Same domain — the `pokemonSynapse` assembled on the previous pages. This is the "provider" way to hand it to the tree; the alternative (manual `await` + prop) is [awaitSynapse](./await-synapse.md), which is what the demo in the module actually uses. ## Creating the context ```typescript import { createSynapseCtx, useSelector } from 'synapse-storage/react' import { pokemonSynapse } from './pokemon.synapse' // the lazy handle from previous pages // Pass the handle ITSELF, not a call. The factory starts lazily on the first mount, not on import. const { contextSynapse, // HOC — wraps a component, providing the context useSynapseStorage, // () => IStorage useSynapseSelectors, // () => PokemonSelectors useSynapseActions, // () => PokemonDispatcher (actions) useSynapseState$, // () => Observable (only with effects) cleanupSynapse, // () => Promise } = createSynapseCtx(pokemonSynapse, { loadingComponent:

Loading the pokedex...
, // shown while the module isn't ready }) ``` ## Using the hooks in child components ```typescript // Child components are called ONLY inside the contextSynapse HOC function PokemonGrid() { const selectors = useSynapseSelectors() const actions = useSynapseActions() const filteredList = useSelector(selectors.filteredList) // reactive values const isListLoading = useSelector(selectors.isListLoading) return (
{filteredList?.map((p) => ( ))} {isListLoading && Loading...}
) } function SearchInput() { const selectors = useSynapseSelectors() const actions = useSynapseActions() const query = useSelector(selectors.searchQuery) return actions.setSearchQuery(e.target.value)} /> } function DirectAccess() { const storage = useSynapseStorage() // Direct access to the storage — e.g. getStateSync(), update(), set() const state = storage.getStateSync() } ``` ## HOC contextSynapse() ```typescript function Pokedex() { const actions = useSynapseActions() return (
) } // Wrap it — loadingComponent is shown while the module isn't ready const PokedexWithContext = contextSynapse(Pokedex) // Usage in JSX: ``` ## useSynapseState$ (only with effects) ```typescript // Available only if effects were passed to the factory (pokemon — yes). // Returns Observable for use with RxJS. const { useSynapseState$ } = createSynapseCtx(pokemonSynapse) function StateLogger() { const state$ = useSynapseState$() useEffect(() => { const sub = state$.subscribe((state) => console.log('selected:', state.selectedPokemonId)) return () => sub.unsubscribe() }, [state$]) } ``` ## Reactive reads in a component Writes still go through actions, but reading can be reactive — straight from the selector's stream (`.$`): ```typescript import { useObservable, useSubscription } from 'synapse-storage/react' function DebouncedSearch() { const selectors = useSynapseSelectors() const debounced = useObservable( () => selectors.searchQuery.$.pipe(debounceTime(300), distinctUntilChanged()), '', [selectors], ) useSubscription(() => selectors.favoriteCount.$.pipe(skip(1), tap(logFavChange)).subscribe(), [selectors]) return
{debounced}
} ``` ## Cleanup ```typescript // Manual cleanup of the context and resources await cleanupSynapse() // For a class-handle it delegates to handle.destroy() (LIFO teardown + memoization reset) — // the next mount will run the factory again. ``` ## Three variants of createSynapseCtx ```typescript // 1. Basic (storage + selectors) // Available: useSynapseStorage, useSynapseSelectors, cleanupSynapse const ctx = createSynapseCtx(basicSynapse) // 2. With a dispatcher (+ actions) // Available: + useSynapseActions const ctx = createSynapseCtx(dispatcherSynapse) // 3. With effects (+ state$) — the pokemon case // Available: + useSynapseState$ const ctx = createSynapseCtx(pokemonSynapse) ``` ## SSR — server-rendering seeded sync stores > Available since **5.0.1**. Classic `renderToString` only (streaming/Suspense is out of scope). > > The full runnable cycle (dehydrate → renderToString → hydration) is in > [`SynapseCtxSsrExample.tsx`](https://github.com/Vlad92msk/synapse/blob/master/packages/examples/src/examples/SynapseCtxSsrExample.tsx) > (on the Posts domain; below the same mechanics are shown on pokemon). By default `createSynapseCtx` gates children behind `loadingComponent` until the module is ready — on the server this yields empty HTML (no SEO, no first paint from server-state). The `ssr: true` flag enables a mode where a synchronously-ready store (Memory/LocalStorage — like pokemon) renders content right away. ### Options ```typescript const PokemonCtx = createSynapseCtx(pokemonSynapse, { loadingComponent: , ssr: true, // enable server-rendering of seeded sync stores }) ``` The `dehydrate` helper and the Provider prop: ```typescript // Server helper: collect a serializable store snapshot. dehydrate(opts?: { initialState?: Partial }): Promise // Provider (any HOC from contextSynapse) accepts the snapshot as a prop: ``` ### Server: build the snapshot `dehydrate` creates a **per-request fork** of the module (parallel requests do not share state — no request bleed), seeds `initialState` via `hydrate`, and returns a serializable snapshot. With `ssr: true` it additionally warms the main handle with the same snapshot, so a synchronous `renderToString` gets a ready store on the first render. ```typescript // Any data-fetching path (the pokemon ApiClient, etc.) → a snapshot. const list = await fetchInitialPokemon() const dehydrated = await PokemonCtx.dehydrate({ initialState: { pokemonList: list } }) const html = renderToString() // serialize into HTML: window.__SYNAPSE_STATE__ = JSON.stringify(dehydrated) ``` > **RSC / `'use client'` boundary.** `createSynapseCtx` is usually called from a `'use client'` > module, so its `dehydrate` (a closure) cannot be imported on the server (RSC / `'server only'`). > For that case there is a **server-safe** `dehydrateModule` from `synapse-storage/utils` — no React > dependencies, takes the module explicitly. `dehydrate` wraps it (same logic, no duplication): > > ```typescript > import { dehydrateModule } from 'synapse-storage/utils' > > // in a server (RSC) file — pokemonSynapse is imported directly, no 'use client' context > const dehydrated = await dehydrateModule(pokemonSynapse, { ssr: true, state: { pokemonList: list } }) > ``` > > `state` is merged on top of the fork's `initialState` (shallow, top-level) — you may pass only the > changed fields; nested objects are replaced wholesale. ### Client: hydrate with the same snapshot The snapshot arrives as a prop and is seeded into the store **synchronously** before the first render → the client HTML matches the server → no hydration mismatch. Init/mutations/lazy-load continue on the client afterwards. ```typescript const dehydrated = JSON.parse(window.__SYNAPSE_STATE__) hydrateRoot(container, ) ``` ### Guarantees and limitations - **Per-request isolation.** `dehydrate` forks the module; `seedHydration` in the Provider re-applies exactly the passed `dehydratedState` synchronously before every render — two parallel server renders with different snapshots never cross. - **Effects do not run on the server.** Consumer subscriptions/`mountedEffect` start only on the client (via `useEffect`, which `renderToString` does not call) — analogous to `enableStaticRendering`. - **Async stores (IndexedDB).** No synchronous server content (async init): the server keeps the previous `loadingComponent` gate, without crashing and without request bleed; `dehydrate` still collects a correct snapshot (it awaits the async `hydrate`). - **Backward compatibility.** Without `ssr` and without `dehydratedState` the behavior is unchanged (lazy start + `loadingComponent`); hook signatures did not change. The full pokemon module — [Pokemon (recipe)](./pokemon-advanced.md). --- # awaitSynapse A React utility for waiting until a Synapse module is ready: HOC + hook + programmatic API. `createSynapse` returns a **lazy handle** (see [create-synapse-basic](./create-synapse-basic.md)) — the factory starts on the first `await`/subscription, not on import. `awaitSynapse` lifts that handle: it kicks off initialization, holds `loadingComponent` during the async prologue, and hands over the ready synapse once `storage` is initialized. Same domain — the `pokemonSynapse` assembled on the previous pages. This is the "manual" way to hand the module to components (no Context): an alternative to the [createSynapseCtx](./synapse-ctx.md) provider. It is exactly `awaitSynapse` that the module's demo uses — `PokemonAdvancedExample.tsx`. ## Creating `awaitSynapse(handle, options?)` is created **once, at module level** — not inside a component, otherwise the awaiter (and initialization) would be recreated on every render. ```typescript import { awaitSynapse } from 'synapse-storage/react' import { pokemonSynapse } from './pokemon.synapse' // pokemonSynapse — the lazy handle from createSynapse (async prologue: initPokemonApi). const pokemonAwaiter = awaitSynapse(pokemonSynapse, { loadingComponent:
Initializing...
, errorComponent: (error) =>
Init failed: {error.message}
, }) ``` `options` is optional: by default `loadingComponent` is `
Initializing...
` and `errorComponent` is the error text. It accepts not only a handle, but also a Promise of a ready synapse or a ready synapse itself. ## withSynapseReady (HOC) — how the demo module is lifted The HOC shows `loadingComponent` while the module isn't ready, and renders the component **only** when `storage` is fully initialized. This is exactly what `PokemonAdvancedExample.tsx` does: ```typescript import { useEffect } from 'react' function PokemonContent() { // The HOC guarantees readiness — store is available synchronously, no undefined checks. const store = pokemonAwaiter.getStoreIfReady()! // Initial list load — once, when the module is ready. useEffect(() => { store.actions.loadList() }, [store]) return } // In JSX it first shows loadingComponent, then PokemonContent with the ready store: export const PokemonAdvancedExample = pokemonAwaiter.withSynapseReady(PokemonContent) ``` Inside `PokemonContent` the whole module surface is available: `store.selectors` (see [selector-system](./selector-system.md)), `store.actions` (the dispatcher's intents), and `store.dispatcher`. ## useSynapseReady (hook) A hook for manual control over readiness — when you need to show an initialization status, not just hide the component behind a loader: ```typescript function PokemonStatus() { const { isReady, isPending, isError, store, error } = pokemonAwaiter.useSynapseReady() if (isPending) return
Loading the module...
if (isError) return
Error: {error?.message}
if (isReady) return
Pokemon loaded: {store!.storage.getStateSync().pokemonList.length}
} // Fields of the returned object: // isReady: boolean — the module is initialized // isPending: boolean — waiting for initialization // isError: boolean — initialization error // store: PokemonSynapse | undefined (defined only when isReady) // error: Error | null ``` ## Programmatic API Available outside React — in effects, utilities, on the server: ```typescript // Synchronous checks pokemonAwaiter.isReady() // boolean pokemonAwaiter.getStatus() // 'pending' | 'ready' | 'error' pokemonAwaiter.getError() // Error | null pokemonAwaiter.getStoreIfReady() // PokemonSynapse | undefined // Asynchronous waiting const store = await pokemonAwaiter.waitForReady() store.actions.loadList() // Callbacks (return an unsubscribe function) const unsub = pokemonAwaiter.onReady((store) => { console.log('Pokemon module ready', store.storage.getStateSync()) }) const unsubErr = pokemonAwaiter.onError((error) => { console.error('Init failed:', error.message) }) // If the module is already ready — onReady fires immediately. // Cleanup pokemonAwaiter.destroy() ``` ## Relation to createSynapseAwaiter `awaitSynapse` is a thin React wrapper around the framework-independent `createSynapseAwaiter`: ```typescript // awaitSynapse adds: withSynapseReady (HOC) and useSynapseReady (hook). // Proxies: waitForReady, isReady, getStoreIfReady, onReady, onError, getStatus, getError, destroy. // For vanilla JS / Node.js / without React — createSynapseAwaiter directly: import { createSynapseAwaiter } from 'synapse-storage/utils' const awaiter = createSynapseAwaiter(pokemonSynapse) // The same programmatic API, but without React hooks. ``` More on the framework-independent variant and the SSR sync-fast-path — on the [synapse-awaiter](./synapse-awaiter.md) page. The full module walkthrough — in the [pokemon-advanced recipe](./pokemon-advanced.md). --- # SSR hydration (hydrate) `storage.hydrate(state)` replaces the storage state with a ready snapshot. The main scenario is **SSR**: the server serializes state (for example, the first page of pokemon), the client initializes the storage with it to avoid flicker and an extra data request. - **Sync storages** (`MemoryStorage`, `LocalStorage`): `hydrate(state): void` - **Async storages** (`IndexedDBStorage`): `hydrate(state): Promise` ## Server → client flow The same logic as a real Next.js `page.tsx`: on the server you fetch the first page and build a serializable snapshot, on the client you seed the store with it before the first render. ```typescript // ── SERVER (Next.js Server Component / page.tsx) ────────────────────────── // Fetch the first page of pokemon and build a store snapshot. async function fetchFirstPokemonOnServer(): Promise<{ pokemonList: PokemonBrief[] }> { const res = await fetch('https://pokeapi.co/api/v2/pokemon?limit=12&offset=0') const data = await res.json() const pokemonList = data.results.map((p) => { const id = Number(p.url.split('/').filter(Boolean).pop()) return { id, name: p.name, sprite: `.../sprites/pokemon/${id}.png` } }) return { pokemonList } // passed as a prop to the client component } ``` ## Hydration before initialize() Called **before** `initialize()`, `hydrate` seeds the storage so that initialization does not overwrite it with `initialState` — the server state wins. ```typescript import { MemoryStorage } from 'synapse-storage/core' const storage = new MemoryStorage<{ pokemonList: PokemonBrief[] }>({ name: 'pokemon-ssr', initialState: { pokemonList: [] }, // default for a "clean" client }) // On the client: the snapshot arrived from the server as a prop storage.hydrate(serverState) await storage.initialize() // initialState will NOT overwrite the hydrated state ``` The first client render already shows the pokemon list — no flicker and no second fetch. ## Hydration after initialize() Called **after** `initialize()`, `hydrate` replaces the state and notifies subscribers (selectors and React hooks update reactively). ```typescript await storage.initialize() // later, e.g. when navigating between pages in an SPA with server data storage.hydrate(nextPageState) // subscribers receive the new state ``` ## With persist migrations If a [`version`](./persist-migration.md) is set, `hydrate` pins the current schema version: the server snapshot is considered already up to date, so no migration runs on it. ## React / createSynapse `hydrate` is available on `synapse.storage` after the module is assembled: ```typescript const synapse = await pokemonSynapse.ready() synapse.storage.hydrate(serverState) ``` It is usually more convenient to work at the module level: [`createSynapseCtx({ ssr: true })`](./synapse-ctx.md) builds the snapshot via `dehydrate` and synchronously seeds the store on the client through the `dehydratedState` prop — solving the same task for the whole module rather than a bare storage. ## Types ```typescript interface ISyncStorage { hydrate(state: T): void // ... } interface IAsyncStorage { hydrate(state: T): Promise // ... } ``` ## See also - [Persist migrations](./persist-migration.md) - [createSynapseCtx](./synapse-ctx.md) · [Pokemon (full example)](./pokemon-advanced.md) --- # Middlewares Middlewares intercept storage operations (set, get, update, delete, clear) and can modify, filter, or group them. They are configured when the store is created — in the `middlewares` field. The examples use the end-to-end domain `TodoState = { todos: Todo[]; filter: Filter }` (see the [MemoryStorage](./memory-storage.md) section). Since middlewares are set at creation time, here we use dedicated todo stores with the needed wiring. ## Configuration ```typescript import { MemoryStorage } from 'synapse-storage/core' const storage = new MemoryStorage({ name: 'my-todo', initialState: { todos: [], filter: 'all' }, middlewares: (getDefault) => [ getDefault().batching({ batchSize: 5, batchDelay: 100 }), getDefault().shallowCompare(), ], }) await storage.initialize() // getDefault() returns an object with the built-in middlewares: // - batching(options?) — grouping of frequent writes // - shallowCompare(options?) — filtering of identical values // - logger(options?) — dev log of write actions // The order in the array = the order of processing ``` ## 1. Batching Middleware ```typescript const storage = new MemoryStorage({ name: 'batching-demo', initialState: { todos: [], filter: 'all' }, middlewares: (getDefault) => [ getDefault().batching({ batchSize: 5, // maximum operations in a single group batchDelay: 100, // delay before the flush (ms) }), ], }) await storage.initialize() // 12 fast set('filter') — only the last value reaches the subscribers const filters = ['all', 'active', 'completed'] as const for (let i = 0; i < 12; i++) { storage.set('filter', filters[i % 3]) } // one notification instead of twelve ``` ## 2. ShallowCompare Middleware ```typescript const storage = new MemoryStorage({ name: 'shallow-demo', initialState: { todos: [], filter: 'all' }, middlewares: (getDefault) => [ getDefault().shallowCompare(), ], }) await storage.initialize() // Setting an identical value — the update will NOT happen storage.set('filter', 'all') // skipped (the value didn't change) // Setting a different value — the update will happen storage.set('filter', 'active') // updated ``` ## 3. ShallowCompare + a custom comparator ```typescript const storage = new MemoryStorage({ name: 'custom-cmp', initialState: { todos: [], filter: 'all' }, middlewares: (getDefault) => [ getDefault().shallowCompare({ // A custom comparison function for a value by key. // Here: treat the task list as "unchanged" if the length is the same. comparator: (prev, next) => { if (Array.isArray(prev) && Array.isArray(next)) { return prev.length === next.length } return prev === next }, }), ], }) await storage.initialize() ``` ## 4. Combining Middlewares ```typescript const storage = new MemoryStorage({ name: 'combined', initialState: { todos: [], filter: 'all' }, middlewares: (getDefault) => [ // Order matters: first filtering, then grouping getDefault().shallowCompare(), getDefault().batching({ batchSize: 3, batchDelay: 50 }), ], }) await storage.initialize() // shallowCompare filters out duplicates, batching groups the rest storage.set('filter', 'all') // skipped (shallowCompare) storage.set('filter', 'all') // skipped (shallowCompare) storage.set('filter', 'active') // passes → into the group ``` ## 5. BroadcastMiddleware (cross-tab synchronization) ```typescript import { MemoryStorage, syncBroadcastMiddleware } from 'synapse-storage/core' const storage = new MemoryStorage({ name: 'broadcast-demo', initialState: { todos: [], filter: 'all' }, middlewares: () => [ syncBroadcastMiddleware({ storageName: 'broadcast-demo', storageType: 'memory', }), ], }) await storage.initialize() // Changes will be synchronized between tabs storage.update((s) => { s.todos.push({ id: 't1', title: 'From another tab', done: false }) }) // For MemoryStorage — full data synchronization // For LocalStorage/IndexedDB — only a subscriber notification // (the data is already synchronized through the storage engine) ``` ## 6. Logger Middleware (dev-only) Logs only **write** actions (`set` / `update` / `delete` / `clear` / `reset` / `init`) — reads (`get` / `keys`) don't add noise. Intentionally minimal (no i18n/colors). Wire it up only in dev. ```typescript const storage = new MemoryStorage({ name: 'logged', initialState: { todos: [], filter: 'all' }, middlewares: (getDefault) => import.meta.env.DEV ? [getDefault().logger()] : [], }) await storage.initialize() storage.set('filter', 'active') // [synapse storage] set "filter" (0ms) // action: { type: 'set', key: 'filter', value: 'active', ... } // prev: { todos: [...], filter: 'all' } // next: { todos: [...], filter: 'active' } // Options: // collapsed?: boolean — collapse the log group (console.groupCollapsed) // showState?: boolean — print the prev/next state (default true) getDefault().logger({ collapsed: true, showState: false }) ``` They are also available as standalone functions `loggerMiddleware` (async) and `syncLoggerMiddleware` (sync) from `synapse-storage/core` — for example, to reuse a single instance or wrap it in your own wrapper. > For a full-fledged dev log of the **dispatcher** (action / prev / next / diff) there is a separate > `loggerDispatcherMiddleware`. The storage logger is about low-level storage operations. ## 7. Custom middleware There are only three built-in middlewares (`batching`, `shallowCompare`, `logger`), but the system is fully open: a middleware is just a **plain object** of type `SyncMiddleware` (for Memory/LocalStorage) or `AsyncMiddleware` (for IndexedDB). No registration is needed — just return your object in the same array as `getDefault()…`. ### Anatomy ```typescript import type { SyncMiddleware } from 'synapse-storage/core' const myMiddleware = (): SyncMiddleware => ({ name: 'my-middleware', // name (for debugging) setup: (api) => {}, // optional: called once when wired up cleanup: () => {}, // optional: called on destroy() // The core — Redux-style triple currying: (api) => (next) => (action) reducer: (api) => (next) => (action) => { // action — what is happening with the storage: // { type: 'set' | 'get' | 'update' | 'delete' | 'clear' | 'reset' | ..., // key?, value?, metadata? } // next(action) — pass control further down the chain (run the operation) // NOT calling next() — block the operation (the write won't happen) // next({ ...action, value: X }) — change the value before writing return next(action) }, }) ``` `api` gives you access to the storage from inside a middleware: ```typescript api.getState() // the whole current state api.storage.doGet(key) // read a value (bypassing the chain) api.storage.doSet(key, value) // write (bypassing the chain — no recursion) api.storage.notifySubscribers(key, value) // notify subscribers manually api.dispatch(action) // run a new action through the whole chain ``` > The chain is wrapped in `try/catch`: if a middleware throws, the error is swallowed by > the error handler and won't "bubble up" to the caller of `storage.set(...)`. > So to "reject" something, don't throw — **simply don't call `next`**. Wiring it up — next to the built-in ones: ```typescript const storage = new MemoryStorage({ name: 'my-todo', initialState: { todos: [], filter: 'all' }, middlewares: (getDefault) => [ getDefault().logger(), // built-in myMiddleware(), // yours ], }) ``` ### Example A — write validation Intercept `set('filter', …)` and **block unknown values**: if the value isn't in the allowed set, the operation never reaches the storage (we return the current value). ```typescript import type { SyncMiddleware } from 'synapse-storage/core' const ALLOWED_FILTERS = ['all', 'active', 'completed'] const validateFilterMiddleware = (): SyncMiddleware => ({ name: 'validate-filter', reducer: (api) => (next) => (action) => { if (action.type === 'set' && action.key === 'filter' && !ALLOWED_FILTERS.includes(action.value)) { // Invalid — block the write, return the current value from storage return api.storage.doGet('filter') } return next(action) }, }) const storage = new MemoryStorage({ name: 'todo-validated', initialState: { todos: [], filter: 'all' }, middlewares: () => [validateFilterMiddleware()], }) await storage.initialize() storage.set('filter', 'archived' as any) // blocked → filter stays 'all' storage.set('filter', 'active') // passes ``` ### Example B — normalizing values A middleware can transform a value instead of blocking it — for example, trim whitespace from task titles. ```typescript import type { SyncMiddleware } from 'synapse-storage/core' const trimTitlesMiddleware = (): SyncMiddleware => ({ name: 'trim-titles', reducer: () => (next) => (action) => { if (action.type === 'set' && action.key === 'todos' && Array.isArray(action.value)) { const value = action.value.map((t) => ({ ...t, title: t.title.trim() })) return next({ ...action, value }) } return next(action) }, }) // storage.set('todos', [{ id, title: ' Buy milk ', done: false }]) // → stored with title 'Buy milk' ``` ### Example C — audit / analytics of changes An "observer" middleware: it changes nothing, it only reacts to write actions (handy for logging to your own analytics, sending metrics, etc.). ```typescript import type { SyncMiddleware } from 'synapse-storage/core' const auditMiddleware = (onChange: (key: string, value: any) => void): SyncMiddleware => ({ name: 'audit', reducer: () => (next) => (action) => { const result = next(action) // run the operation first if (action.type === 'set' && typeof action.key === 'string') { onChange(action.key, action.value) } return result }, }) const storage = new MemoryStorage({ name: 'todo-audited', initialState: { todos: [], filter: 'all' }, middlewares: () => [ auditMiddleware((key, value) => analytics.track('todo_changed', { key, value })), ], }) ``` > For an **asynchronous** storage (IndexedDB) everything is the same, only the type is > `AsyncMiddleware`, and `reducer`/`api.storage.*` return a `Promise` (use `async/await` > and `return await next(action)`). ## Types ```typescript import type { SyncMiddleware, // Middleware for synchronous storages (Memory, LocalStorage) AsyncMiddleware, // Middleware for asynchronous storages (IndexedDB) SyncMiddlewareAPI, // The API available inside a middleware (getState, dispatch) AsyncMiddlewareAPI, StorageAction, // { type: 'set'|'get'|'delete'|'clear', key?, value? } SyncStorageConfig, // Config with middlewares?: ConfigureSyncMiddlewares AsyncStorageConfig, // Config with middlewares?: ConfigureAsyncMiddlewares BatchingMiddlewareOptions, // { batchSize?, batchDelay? } ShallowCompareMiddlewareOptions, // { comparator?, segments? } } from 'synapse-storage/core' // Middleware configuration — a callback with getDefault type ConfigureSyncMiddlewares = ( getDefault: () => SyncDefaultMiddlewares ) => SyncMiddleware[] interface SyncDefaultMiddlewares { batching(options?: BatchingMiddlewareOptions): SyncMiddleware shallowCompare(options?: ShallowCompareMiddlewareOptions): SyncMiddleware logger(options?: LoggerMiddlewareOptions): SyncMiddleware } // A similar AsyncDefaultMiddlewares for IndexedDB // Standalone factories: // loggerMiddleware(options?): AsyncMiddleware // syncLoggerMiddleware(options?): SyncMiddleware // LoggerMiddlewareOptions = { collapsed?: boolean; showState?: boolean } ``` --- # SharedWorkerMiddleware `sharedWorkerMiddleware` / `syncSharedWorkerMiddleware` synchronize storage state between tabs through a **SharedWorker**. It is a direct mirror of [`broadcastMiddleware`](./middlewares.md): same role, same signature — only the transport differs. Where `broadcastMiddleware` uses a `BroadcastChannel`, `sharedWorkerMiddleware` routes messages through a single SharedWorker shared by every tab of the origin. Like all middlewares, it is wired up at store creation time in the `middlewares` field. ## Which one to import There are two factories with an identical signature: - **`syncSharedWorkerMiddleware`** — for synchronous storages (`MemoryStorage`, `LocalStorage`). - **`sharedWorkerMiddleware`** — for the asynchronous `IndexedDBStorage` (and `WorkerCacheStorage`). ```typescript import { MemoryStorage, syncSharedWorkerMiddleware } from 'synapse-storage/core' const storage = new MemoryStorage({ name: 'shared-worker-demo', initialState: { todos: [], filter: 'all' }, middlewares: () => [ syncSharedWorkerMiddleware({ storageName: 'shared-worker-demo', storageType: 'memory', }), ], }) await storage.initialize() // Changes are synchronized between tabs storage.update((s) => { s.todos.push({ id: 't1', title: 'From another tab', done: false }) }) ``` The signature is the same `{ storageType, storageName }` as `broadcastMiddleware` — swapping one for the other is a one-line change. ## What gets synchronized The behaviour matches `broadcastMiddleware` exactly: - **MemoryStorage** — full data synchronization. A newly opened tab requests the current state from the worker (`requestSync`) and seeds itself, then stays in sync on every write. - **LocalStorage / IndexedDB** — only a subscriber notification. The data itself is already shared by the browser storage engine; the middleware just tells the subscribers of other tabs to re-read. ## N stores over ONE SharedWorker The key difference from `broadcastMiddleware` is transport multiplexing. Each store gets its own logical channel named `${storageType}-${storageName}`, but **all** channels are multiplexed over a **single** SharedWorker instance per origin. Ten stores in a tab do not spawn ten workers — they share one, and messages are demultiplexed by channel name. ```typescript // Both stores below travel through the SAME SharedWorker, // isolated by their channel: 'memory-todos' vs 'memory-settings'. const todos = new MemoryStorage({ name: 'todos', initialState: { todos: [], filter: 'all' }, middlewares: () => [syncSharedWorkerMiddleware({ storageType: 'memory', storageName: 'todos' })], }) const settings = new MemoryStorage({ name: 'settings', initialState: { theme: 'light' }, middlewares: () => [syncSharedWorkerMiddleware({ storageType: 'memory', storageName: 'settings' })], }) ``` > The channel name is `${storageType}-${storageName}`. Two stores with the same > `storageType` + `storageName` share one channel — keep the pair unique per logical store. ## Fallback and SSR - **No SharedWorker?** The transport transparently falls back to a `BroadcastChannel`, so cross-tab sync keeps working in browsers/contexts without SharedWorker support. - **SSR / no browser APIs?** The middleware is a no-op — there is no window, no other tab to sync with, and store creation does not throw. ## When to use which - **`broadcastMiddleware`** — the simple default. One `BroadcastChannel` per store, no worker. Fine for a handful of stores. - **`sharedWorkerMiddleware`** — when you sync **many** stores and want them multiplexed over a single SharedWorker instead of many independent channels, or when you already run a SharedWorker and want to reuse it. Cross-tab semantics are identical. For a **live shared cache** (not just notifications) held inside the worker itself, see [WorkerCacheStorage](./worker-cache-storage.md). ## Types ```typescript import type { Middleware, SyncMiddleware } from 'synapse-storage/core' // Both factories take the same props: interface SharedWorkerMiddlewareProps { storageType: StorageType // 'memory' | 'localStorage' | 'indexedDB' | string storageName: string // used together with storageType as the channel key } // syncSharedWorkerMiddleware(props): SyncMiddleware — Memory / LocalStorage // sharedWorkerMiddleware(props): Middleware — IndexedDB (async) ``` --- # Singleton Pattern Reusing storage instances by name. Useful for shared state and when a storage is created in several places (React components, modules). The examples use the end-to-end domain `TodoState = { todos: Todo[]; filter: Filter }` (see the [MemoryStorage](./memory-storage.md) section). ## Enabling Singleton ```typescript import { MemoryStorage } from 'synapse-storage/core' // First instance — creates the storage const storage1 = new MemoryStorage({ name: 'my-todo', singleton: { enabled: true }, initialState: { todos: [], filter: 'completed' }, }) await storage1.initialize() // Second instance with the SAME name — gets the same object const storage2 = new MemoryStorage({ name: 'my-todo', singleton: { enabled: true }, initialState: { todos: [], filter: 'all' }, // ignored (FIRST_WINS by default) }) await storage2.initialize() storage2.get('filter') // 'completed' (the same instance!) storage1 === storage2 // true // Works with MemoryStorage, LocalStorage, IndexedDB // Default singleton key: `${storageType}_${name}` (memory_my-todo) ``` ## Merge strategies (mergeStrategy) ```typescript import { MemoryStorage, ConfigMergeStrategy } from 'synapse-storage/core' const storage = new MemoryStorage({ name: 'my-todo', singleton: { enabled: true, mergeStrategy: ConfigMergeStrategy.FIRST_WINS, // default }, initialState: { todos: [], filter: 'all' }, }) // All strategies: // FIRST_WINS (default) // The first initialState wins, subsequent ones are ignored // DEEP_MERGE // Recursive merge of initialState: // s1: { todos: [], filter: 'all' } // s2: { filter: 'active' } // → { todos: [], filter: 'all' } (the first one's fields take priority) // OVERRIDE // The last configuration overrides (except name) // WARN_AND_USE_FIRST // Like FIRST_WINS, but with a console.warn on conflicts // STRICT // Throws an Error if initialState differs ``` ## Custom key (singleton.key) ```typescript // Default key: `${storageType}_${name}` // Two storages with the same name but a different key — different instances const active = new MemoryStorage({ name: 'todo-board', singleton: { enabled: true, key: 'board-active' }, initialState: { todos: [], filter: 'active' }, }) const archive = new MemoryStorage({ name: 'todo-board', // the same name! singleton: { enabled: true, key: 'board-archive' }, // a different key initialState: { todos: [], filter: 'completed' }, }) active === archive // false (different keys → different instances) ``` ## Singleton in React ```typescript import { useStorageSubscribe } from 'synapse-storage/react' // Two components create a storage with the same name — a single instance const sharedStorage = new MemoryStorage({ name: 'shared-todo', singleton: { enabled: true }, initialState: { todos: [], filter: 'all' }, }) sharedStorage.initialize() function ComponentA() { const count = useStorageSubscribe(sharedStorage, (s) => s.todos.length) return
tasks: {count}
} function ComponentB() { // Creates a "new" storage — but gets the same singleton const sameStorage = new MemoryStorage({ name: 'shared-todo', singleton: { enabled: true }, initialState: { todos: [], filter: 'all' }, }) const count = useStorageSubscribe(sameStorage, (s) => s.todos.length) // count here = the same as in ComponentA return
tasks: {count}
} ``` ## Full SingletonOptions configuration ```typescript interface SingletonOptions { enabled: boolean // enable singleton mergeStrategy?: ConfigMergeStrategy // merge strategy (default: FIRST_WINS) warnOnConflict?: boolean // console warning (default: true) key?: string // custom key (default: `${type}_${name}`) } // The ConfigMergeStrategy enum: enum ConfigMergeStrategy { STRICT = 'strict', FIRST_WINS = 'first_wins', DEEP_MERGE = 'deep_merge', OVERRIDE = 'override', WARN_AND_USE_FIRST = 'warn_and_use_first', } ``` --- # Persist migrations (version + migrate) When the shape of `initialState` changes between releases, a persistent storage (`LocalStorage` / `IndexedDBStorage`) still holds **old-schema** data. The `version` and `migrate` config options transform it to the current schema on initialization — without manual version checks and without losing user data. For `MemoryStorage` these options are ignored (nothing to persist). Without `version` behavior is unchanged — migration is off. ## How it works A real case: in v1 favorite pokemon were stored **by name**, in v2 — **by id**. `migrate` converts the saved names to ids once, on initialization. ```typescript import { LocalStorage } from 'synapse-storage/core' interface PokemonPrefs { favorites: number[] // v2: ids (used to be names) } const NAME_TO_ID: Record = { pikachu: 25, charizard: 6, bulbasaur: 1 } const storage = new LocalStorage({ name: 'pokemon-prefs', version: 2, // current schema version initialState: { favorites: [] }, migrate: (oldState, oldVersion) => { // v1 → v2: names → ids if (oldVersion < 2) { return { favorites: (oldState.favorites ?? []).map((n: string) => NAME_TO_ID[n]).filter(Boolean) } } return oldState }, }) await storage.initialize() ``` On `initialize()`: 1. Storage is empty → `initialState` is written and the current `version` is pinned. 2. Data exists, saved version **equals** current → data is used as is. 3. Data exists, saved version **below** current → `migrate(oldState, oldVersion)` is called, the result is written, the version is updated. 4. Saved version **above** current (an older build is open) → data is left untouched (+ a dev warning). The version is stored **next to** the data, not polluting the state itself: - **LocalStorage** — a separate sidecar key `${name}::__synapse_version__`. - **IndexedDB** — a reserved `__synapse_version__` record in the same store. It is excluded from `getState()` / `keys()` and survives `clear()` / a full state overwrite. ## Bumping the version without migrate If you bump `version` but don't provide `migrate`, the old-schema data stays as is and the version is updated. In dev mode a warning is printed — usually this is a mistake (a forgotten migration). ```typescript const storage = new LocalStorage({ name: 'pokemon-prefs', version: 3, // bumped initialState: { favorites: [] }, // migrate not provided → old data stays, version becomes 3 (+ dev warn) }) ``` ## migrate runs once After a successful migration the new version is written, so on subsequent runs with the same `version` the `migrate` function is no longer called. Migration is idempotent per version. ## SSR / hydration If the storage is hydrated with a server snapshot via [`hydrate(state)`](./ssr-hydration.md), the snapshot is considered to already match the current schema — the current `version` is pinned and no migration runs on it. ## Types ```typescript import type { MigrateFn } from 'synapse-storage/core' // (persistedState, persistedVersion) => normalized state of the current schema type MigrateFn = (persistedState: any, persistedVersion: number) => T interface BaseStorageConfig { name: string initialState?: T version?: number migrate?: MigrateFn // ... } ``` ## See also - [LocalStorage](./local-storage.md) · [IndexedDB Storage](./indexeddb-storage.md) - [SSR hydration](./ssr-hydration.md) --- # createSynapseAwaiter — framework-independent awaiter A utility for waiting on a Synapse module's async initialization. Works in any JS environment: Node.js, browser, React Native, workers. The React wrapper over it is [awaitSynapse](./await-synapse.md) (it adds the `withSynapseReady` HOC and the `useSynapseReady` hook); the method surface itself (`waitForReady`/`isReady`/`getStoreIfReady`/`onReady`/`onError`/`getStatus`/`getError`/`destroy`) is proxied by `awaitSynapse` straight from here. It takes the lazy handle from `createSynapse` (see [create-synapse-basic](./create-synapse-basic.md)), starts its initialization, and gives synchronous/asynchronous ways to wait for `storage` to be ready. ## Imports and creation ```typescript import { createSynapseAwaiter } from 'synapse-storage/utils' import { pokemonSynapse } from './pokemon.synapse' // Accepts a lazy handle (typical case — the initPokemonApi async prologue in the factory), // a Promise of a ready synapse, or a ready synapse itself. const pokemonAwaiter = createSynapseAwaiter(pokemonSynapse) // Variant with an already-ready module: const ready = await pokemonSynapse const awaiter2 = createSynapseAwaiter(ready) ``` ## Programmatic surface ```typescript // Synchronous checks pokemonAwaiter.isReady() // boolean pokemonAwaiter.getStatus() // 'pending' | 'ready' | 'error' pokemonAwaiter.getError() // Error | null pokemonAwaiter.getStoreIfReady() // PokemonSynapse | undefined // Asynchronous waiting — Promise. Safe to call repeatedly: the same store. const store = await pokemonAwaiter.waitForReady() store.actions.loadList() // Callbacks (return an unsubscribe function). onReady on a ready module fires immediately. const unsub = pokemonAwaiter.onReady((store) => { console.log('pokemon ready, list:', store.storage.getStateSync().pokemonList.length) }) const unsubErr = pokemonAwaiter.onError((error) => console.error('init failed:', error.message)) // Cleanup: resets subscriptions, status -> 'pending', store -> undefined. pokemonAwaiter.destroy() ``` `getStoreIfReady()` returns the assembled module — its shape depends on the `createSynapse` configuration. For `pokemonSynapse` that's the full set: `storage`, `selectors`, `dispatcher`/`actions`, `state$`, `destroy()`. Until ready — `undefined`. ## SSR sync-fast-path The key difference from ordinary waiting: if the input is an **already-ready** synapse (or a handle whose `getSnapshot()` returns a synapse with a `READY` storage), the awaiter sets `store` and `status = 'ready'` **synchronously in the function body**, before returning — no microtask. Then `getStoreIfReady()`/`isReady()` return the store on the first synchronous render, which is exactly what server `renderToString` needs. A not-yet-warmed handle falls back to the ordinary async path. ```typescript // On the server: warm the module first, then the awaiter resolves synchronously. await pokemonSynapse.ready() // the factory has run, storage is READY const awaiter = createSynapseAwaiter(pokemonSynapse) awaiter.isReady() // true — synchronously, no await awaiter.getStoreIfReady() // PokemonSynapse, available in the same tick // → renderToString sees the ready state on the first pass ``` The full SSR flow (dehydrate on the server → hydrate on the client) — on the [createSynapseCtx](./synapse-ctx.md) page. ## Usage in React (without the wrapper) If you'd rather not pull in the HOC/hook from `awaitSynapse`, the awaiter can be used manually via subscriptions: ```typescript function PokemonStatus() { const [status, setStatus] = useState(pokemonAwaiter.getStatus()) const [count, setCount] = useState(0) useEffect(() => { const unsubReady = pokemonAwaiter.onReady((store) => { setStatus('ready') setCount(store.storage.getStateSync().pokemonList.length) }) const unsubError = pokemonAwaiter.onError(() => setStatus('error')) // If the module is already ready at mount time — sync up right away. if (pokemonAwaiter.isReady()) { setStatus('ready') setCount(pokemonAwaiter.getStoreIfReady()?.storage.getStateSync().pokemonList.length ?? 0) } return () => { unsubReady(); unsubError() } }, []) if (status === 'pending') return
Loading the module...
if (status === 'error') return
Initialization error
return
Pokemon loaded: {count}
} ``` > In a real React app this case is better served by [awaitSynapse](./await-synapse.md) — it encapsulates > exactly this subscription in a HOC/hook. `createSynapseAwaiter` is needed where there is no React > (Node render, data preload, scripts) or where the sync-fast-path is required. The full module walkthrough — in the [pokemon-advanced recipe](./pokemon-advanced.md). --- # createEventBus — Event Bus A pub/sub bus for communication **between independent modules**. Built on the same bricks as the rest of the BLL: `createSynapse` + `MemoryStorage` + `Dispatcher` (see [create-synapse-basic](./create-synapse-basic.md)). Supports wildcard patterns, priorities, TTL, and event history. Where this fits our domain: the pokemon module (see [pokemon-advanced](./pokemon-advanced.md)) only knows about itself — it loads the list, tracks favorites, holds the selected pokemon. If **other** parts of the app should react to those actions (analytics, toasts, a header badge), there's no need to wire them together tightly. Pokemon publishes domain events (`POKEMON_SELECTED`, `FAVORITE_TOGGLED`), and anyone subscribes to them. This is the "pattern 3 / mediator" from [dependencies](./dependencies.md), just packaged as a ready-made utility. > The reference pokemon module does **not** bake the bus in — event-bus is an optional integration on > top of it, so this page has no canonical pokemon file, only a runnable sandbox. ## Imports ```typescript import { createEventBus } from 'synapse-storage/utils' ``` ## Creating ```typescript const eventBusHandle = createEventBus({ name: 'pokemon-events', // name (for singleton/debugging) autoCleanup: true, // auto-cleanup of old events maxEvents: 1000, // max stored events (default 1000) }) // createEventBus returns a SynapseModule handle (lazy, PromiseLike) — // the factory runs on the first await/ready() const eventBus = await eventBusHandle // The result (Synapse): // { // storage: IStorage — the state storage // actions: EventBusDispatcher — typed actions (alias of dispatcher) // dispatcher: EventBusDispatcher — the same dispatcher instance // selectors: undefined — the bus has no selectors // state$: Observable — the state stream (always present) // destroy: () => Promise — cleanup // } // EventBusState: // { // events: Record // subscriptions: Record // } ``` `actions` and `dispatcher` are the same `EventBusDispatcher` instance; its fields (`publish`/`subscribe`/…) are the dispatch functions. Everything below uses `eventBus.actions`. ## actions.publish() — Publishing an event ```typescript const eventBus = await createEventBus({ name: 'pokemon-events' }) // Publishing an event const result = await eventBus.actions.publish({ event: 'POKEMON_SELECTED', // event type (string) data: { id: 25, name: 'pikachu' }, // arbitrary data metadata: { // optional metadata priority: 'high', // 'low' | 'normal' | 'high' ttl: 60000, // event time-to-live (ms) }, }) // The result: // { // eventId: string — the unique event ID // event: string — the event type // data: any — the data // } // EventBusEvent (stored in storage): // { // id: string // event: string // data: any // metadata: { ttl?: number | null, priority?: 'low' | 'normal' | 'high' } // timestamp: number // } ``` ## actions.subscribe() — Subscribing to events ```typescript // Subscribing to a specific event const { subscriptionId, unsubscribe } = await eventBus.actions.subscribe({ eventPattern: 'POKEMON_SELECTED', // exact match handler: (data, event) => { // data — event.data (the payload) // event — the full EventBusEvent object console.log(data) // { id: 25, name: 'pikachu' } console.log(event.event) // 'POKEMON_SELECTED' console.log(event.timestamp) // 1716633600000 }, }) // Wildcard patterns await eventBus.actions.subscribe({ eventPattern: 'POKEMON_*', // all events starting with POKEMON_ handler: (data, event) => { // POKEMON_SELECTED, POKEMON_LOADED, ... console.log(event.event, data) }, }) await eventBus.actions.subscribe({ eventPattern: '*', // ALL events handler: (data, event) => { console.log('Any event:', event.event) }, }) // Filter by priority await eventBus.actions.subscribe({ eventPattern: 'FAVORITE_*', handler: (data, event) => { ... }, options: { priority: 'high' }, // only high-priority events }) // Unsubscribe unsubscribe() ``` Internally `subscribe` listens to the `state.events` slice of storage: when a new event is published, every subscriber whose pattern matches gets a `handler` call. An error in a handler does not bring the bus down — it is logged through the internal `handleCallbackError`. ## actions.getEventHistory() — Event history ```typescript // Get the history for an event type const history = await eventBus.actions.getEventHistory({ eventType: 'POKEMON_SELECTED', // the event type limit: 10, // max records (default 100) }) // Returns EventBusEvent[] — sorted by timestamp (newest first) // [ // { id: '...', event: 'POKEMON_SELECTED', data: {...}, timestamp: 1716633600000 }, // { id: '...', event: 'POKEMON_SELECTED', data: {...}, timestamp: 1716633500000 }, // ] ``` ## actions.getActiveSubscriptions() — Active subscriptions ```typescript const subscriptions = await eventBus.actions.getActiveSubscriptions() // Returns an array: // [ // { // id: string, — the subscription ID // pattern: string, — the pattern ('POKEMON_*', '*', etc.) // options: {...}, — options (priority etc.) // createdAt: number, — creation time // } // ] ``` ## actions.clearEvents() — Clearing events ```typescript // Clear old events await eventBus.actions.clearEvents({ olderThan: 60000, // remove events older than 60 seconds }) // Clear all events await eventBus.actions.clearEvents({}) ``` With `autoCleanup: true` old events are trimmed automatically on every publish: as soon as their count exceeds `maxEvents`, only the `maxEvents` freshest ones (by `timestamp`) are kept. ## destroy() ```typescript // Full cleanup: active subscriptions, storage, dispatcher await eventBus.destroy() ``` `destroy()` first calls every accumulated `unsubscribe`, then tears the module down and resets the handle's memoization (the next `await eventBusHandle` rebuilds the bus from scratch). ## Example: pokemon publishes, other modules listen ```typescript // pokemon-events.ts — the shared domain bus import { createEventBus } from 'synapse-storage/utils' export const pokemonEventsHandle = createEventBus({ name: 'pokemon-events', autoCleanup: true }) // ─── pokemon-side: publish domain events ───────────────────────────────────── // A handy place is a wrapper over dispatcher intents (see dispatcher-detailed) // or an effect that already sees the module's action stream. const bus = await pokemonEventsHandle export async function selectAndAnnounce(store: PokemonSynapse, pokemon: PokemonBrief) { store.actions.selectPokemon(pokemon.id) await bus.actions.publish({ event: 'POKEMON_SELECTED', data: { id: pokemon.id, name: pokemon.name }, metadata: { priority: 'high' }, }) } // ─── analytics.ts — listens to all domain events ───────────────────────────── const bus = await pokemonEventsHandle bus.actions.subscribe({ eventPattern: 'POKEMON_*', handler: (data, event) => { analytics.track(event.event, data) // POKEMON_SELECTED, FAVORITE_TOGGLED, ... }, }) // ─── toaster.ts — reacts only to favorites ─────────────────────────────────── bus.actions.subscribe({ eventPattern: 'FAVORITE_TOGGLED', handler: (data) => { showToast(`Pokemon ${data.name} ${data.added ? 'added to' : 'removed from'} favorites`) }, }) ``` The `analytics` and `toaster` modules know nothing about the pokemon synapse and don't import it — the only link is the event names. That decoupling is the whole point of the bus. ## Relation to createSynapse: the bus as an externalDispatcher If you need not just to listen to events from outside, but to **feed** them into another synapse's action stream (so its effects react to bus events like regular actions), pass the bus through `externalDispatchers` — this is "communication variant 3" from [dependencies](./dependencies.md): ```typescript const bus = await pokemonEventsHandle const mySynapse = createSynapse(() => ({ storage, dispatcher: new MyDispatcher(storage), effects: new MyEffects(), externalDispatchers: { eventBus: bus.dispatcher }, // bus actions land in action$ })) ``` ## See also - [dependencies](./dependencies.md) — patterns for module communication (the bus = mediator / externalDispatchers). - [create-synapse-basic](./create-synapse-basic.md) — what the bus itself is built from (storage + dispatcher). - [pokemon-advanced](./pokemon-advanced.md) — the reference module whose events the bus publishes. ``` --- # Caching layers A request in a synapse app can be answered from **three independent layers**, stacked on top of each other. They are not competing strategies — each layer solves a different problem, knows different things, and you enable them independently. This page explains the stack once, so the individual docs ([ApiClient](./api-client.md), [WorkerCacheStorage](./worker-cache-storage.md), the [ServiceWorker](./custom-fetch-service-worker.md) and [fetchFn](./custom-fetch-fn.md) recipes) can stay focused on their own layer. ``` your component / effect │ ▼ ┌───────────────────────────────────────────────┐ │ 1. APPLICATION CACHE — ApiClient + storage │ knows YOUR domain: endpoints, tags, ttl │ (Memory / LocalStorage / IndexedDB / │ hit ⇒ no fetch() at all, no Network row │ WorkerCacheStorage) │ └───────────────────────────────────────────────┘ │ cache miss → client calls fetch() ▼ ┌───────────────────────────────────────────────┐ │ 2. TRANSPORT — baseQuery.fetchFn │ HOW the request is performed │ (auth-retry, metrics, axios, worker) │ not a cache — the seam between layers └───────────────────────────────────────────────┘ │ ▼ ┌───────────────────────────────────────────────┐ │ 3. NETWORK LAYER — your ServiceWorker │ knows URLs and HTTP: offline, precache, │ + Cache API (below it: browser HTTP cache) │ strategies; Network row "(ServiceWorker)" └───────────────────────────────────────────────┘ │ ▼ network ``` ## The request path What actually happens when a hook or an endpoint fires a query: 1. **`ApiClient` checks its own cache** in `storage` (by cache key, ttl, tags). On a valid hit the response is served from the storage — `fetch()` is **never called**, layers 2–3 never see the request, and DevTools → Network shows **no row at all**. 2. On a miss the client performs the request through the **transport** — the built-in `fetch`, or your `baseQuery.fetchFn` if you provided one. 3. That `fetch()` call is what a **ServiceWorker** can intercept: it answers from its Cache API, goes to the network, or synthesizes an offline fallback. Below it sits the regular browser HTTP cache. 4. The response travels back up: the SW returns it to `fetch`, the client stores it in its application cache (if caching is enabled for that endpoint) and resolves your query. Two consequences worth internalizing: - The layers **shadow** each other top-down: a layer-1 hit means the lower layers do nothing. - Invalidation lives **per layer**: `invalidatesTags` cleans layer 1 only; an SW cache honors its own strategy (stale-while-revalidate etc.) and knows nothing about your tags. ## Layer 1 — application cache (storage) This is `ApiClient`'s own cache, kept in whatever storage you give it. It stores **parsed data** and understands **your API's semantics**, which is why it can do things no lower layer can: - **Tag invalidation** — a mutation with `invalidatesTags: ['notes']` drops every cached query tagged `notes` and auto-refetches the active ones. An SW can't do this: it sees opaque URL→bytes. - **Reactivity** — hooks re-render from cache updates and subscriptions. - **Sync fast-path** — on sync storages (`MemoryStorage`, `LocalStorage`) a cached result is readable synchronously on the very first render (no loading flash), important for SSR hydration. - **Caching is opt-in and GET-only** — with no `cache` config nothing is cached; mutations are never cached. The storage you pick decides the cache's *scope and lifetime*: | Storage | Scope | Survives reload | Sync fast-path | |---|---|---|---| | `MemoryStorage` | one tab | no | yes | | `LocalStorage` | per tab, data shared via disk | yes | yes | | `IndexedDBStorage` | per tab, data shared via disk | yes | no | | `WorkerCacheStorage` | **live, all tabs at once** | while any tab lives | no | `WorkerCacheStorage` is still layer 1 — it just moves the cache's `Map` into a SharedWorker so all tabs share one live cache (one tab fetches, every tab hits cache). ## Layer 2 — transport (baseQuery.fetchFn) Not a cache — the **seam** between the layers: it replaces *how* the client performs a request once layer 1 missed. Auth-retry, metrics/tracing, an axios instance, a `postMessage` bridge to a Worker. Layer 1 keeps working unchanged on top of it, and whatever the transport ultimately `fetch`es is still interceptable by layer 3. See the [Custom `baseQuery.fetchFn`](./custom-fetch-fn.md) recipe. ## Layer 3 — network (your ServiceWorker + Cache API) An app-owned `sw.js` registered next to (not inside) the library. It intercepts the platform `fetch` and implements **network policy**: precache of the shell, offline fallbacks, per-URL strategies (cache-first / network-first / stale-while-revalidate). It knows URLs and HTTP — not your endpoints or tags — and also covers requests your code didn't make (images, fonts). Because it sits below `fetch`, every request it serves still shows in Network, tagged `(ServiceWorker)`. See the [Custom fetch-intercepting ServiceWorker](./custom-fetch-service-worker.md) recipe. Below the SW there is a fourth, free layer you don't manage: the browser HTTP cache (`Cache-Control` headers → `(from disk cache)` rows). ## Other libraries do the same This split is the standard architecture of the web, not a synapse invention. TanStack Query, SWR, RTK Query and Apollo all keep their own application-level cache and none of them delegates it to a ServiceWorker; Workbox exists precisely as the layer-3 tool, and a typical PWA runs an app cache **plus** Workbox side by side. The browser adds the HTTP cache underneath all of them. Layered caching with different knowledge at each layer is the norm. ## Choosing a setup - **Data-driven SPA (default)** — layer 1 only: `cache` config + tags on the `ApiClient`. No SW. - **The same, shared across tabs** — layer 1 with `WorkerCacheStorage` as the client's storage. - **PWA / offline** — layer 1 for data semantics **plus** your own SW (layer 3) for offline and precache. The layers don't conflict: a layer-1 hit just never reaches the SW. - **"Cache only in the ServiceWorker"** — perfectly valid: leave `ApiClient` caching off (it's opt-in) and let your SW own caching. You keep endpoint typing and request state, but give up tag invalidation, auto-refetch after mutations and the sync fast-path — every hit is a full `fetch` round-trip through the SW. - **Auth / metrics / custom transport** — layer 2 (`fetchFn`), orthogonal to all of the above. ## DevTools Network cheat-sheet The Network tab tells you which layer answered: | What you see | Who answered | |---|---| | no row at all | layer 1 — application cache (incl. `WorkerCacheStorage`) | | row tagged `(ServiceWorker)` | layer 3 — your SW (from its Cache API or network) | | row `(from disk cache)` / `(memory cache)` | browser HTTP cache | | plain row with timing | real network | --- # ApiClient — HTTP Client with Caching A typed HTTP client: endpoints, tag-based caching, request-state subscriptions, abort. > **New to the caching stack?** Start with [Caching layers](./cache-layers.md) — where the client's > cache sits relative to a ServiceWorker and `fetchFn`, and when to use which. This page is built around the **real `pokemon.api.ts` file** from the [`pokemon-advanced`](./pokemon-advanced.md) example. The same `pokemonApiClient` is later used in the effects (see [Effects](./create-synapse-effects.md)) — it's the first brick of the data layer. ## Imports ```typescript import { ApiClient } from 'synapse-storage/api' import { MemoryStorage } from 'synapse-storage/core' ``` ## Creating the ApiClient (`pokemon.api.ts`) `pokemon.api.ts` is a **single responsibility of the module**: configuring the client, describing the endpoints, and mapping the raw response into domain types. Nothing extra. ```typescript // ─── Raw API response types ───────────────────────────────────────────────── // They describe the shape PokeAPI returns — which we hide behind mappers. interface PokemonListApiResponse { count: number next: string | null results: Array<{ name: string; url: string }> } interface PokemonApiResponse { id: number name: string types: Array<{ type: { name: string } }> stats: Array<{ stat: { name: string }; base_stat: number }> abilities: Array<{ ability: { name: string } }> sprites: { front_default: string } height: number weight: number } // ─── ApiClient ────────────────────────────────────────────────────────────── export const pokemonApiClient = new ApiClient({ // The request-cache storage (required). storage: new MemoryStorage>({ name: 'pokemon-advanced-api-cache', initialState: {}, }), baseQuery: { baseUrl: 'https://pokeapi.co/api/v2', timeout: 10000, // request timeout (ms) }, cache: { ttl: 60000, // global cache time-to-live (ms) invalidateOnError: true, // invalidate cache on error }, endpoints: async (create) => ({ // GET — a list with query params getList: create<{ limit: number; offset: number }, PokemonListApiResponse>({ request: (params) => ({ path: '/pokemon', method: 'GET', query: params, // -> ?limit=12&offset=0 }), cache: { ttl: 120000 }, // its own TTL for this endpoint tags: ['pokemon-list'], // tags for invalidation }), // GET — a single resource by ID (param in the path) getDetails: create<{ id: number }, PokemonApiResponse>({ request: ({ id }) => ({ path: `/pokemon/${id}`, // -> /pokemon/25 method: 'GET', }), cache: true, // uses the global TTL tags: ['pokemon-details'], }), }), }) // Client initialization (see "Lifecycle" below). export const initPokemonApi = () => pokemonApiClient.init() // The endpoints-set type — passed into the effects as a service. export type PokemonApiEndpoints = ReturnType ``` ## Response mappers Endpoints return the **raw** API response. Mappers turn it into domain types (`PokemonBrief` / `PokemonDetails` from [`pokemon.types.ts`](./pokemon-advanced.md)) so the rest of the data layer works only with a clean domain shape. ```typescript import type { PokemonBrief, PokemonDetails } from './pokemon.types' export function mapListResponse(data: PokemonListApiResponse): { list: PokemonBrief[]; hasMore: boolean } { const list: PokemonBrief[] = data.results.map((p) => { // PokeAPI doesn't return an id in the list — we pull it from the url. const id = parseInt(p.url.split('/').filter(Boolean).pop()!) return { id, name: p.name, sprite: `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`, } }) return { list, hasMore: !!data.next } } export function mapDetailsResponse(data: PokemonApiResponse): PokemonDetails { return { id: data.id, name: data.name, types: data.types.map((t) => t.type.name), stats: data.stats.map((s) => ({ name: s.stat.name, value: s.base_stat })), abilities: data.abilities.map((a) => a.ability.name), sprite: data.sprites.front_default, height: data.height, weight: data.weight, } } ``` > **Why the mappers live here, not in the effects:** the API response shape is a transport detail. > Keeping mappers next to the endpoints localizes the knowledge of PokeAPI in one file; effects and > selectors only ever see the domain types. ## request() — performing a request ```typescript // pokemonApiClient.request(endpointName, params, options?) // Returns Promise> const result = await pokemonApiClient.request('getList', { limit: 12, offset: 0 }) // QueryResult: // { // ok: boolean — whether the request succeeded // data?: T — the response data (typed) // error?: Error — the error (if ok = false) // status: number — HTTP status (200, 404, ...) // statusText: string — HTTP status text // headers: Headers — response headers // fromCache?: boolean — was the result from cache? // } if (result.ok) { console.log(result.data) // PokemonListApiResponse (typed) console.log(result.fromCache) // false (first request) } // A repeat request with the same params — from cache const cached = await pokemonApiClient.request('getList', { limit: 12, offset: 0 }) console.log(cached.fromCache) // true ``` ## QueryOptions — request options ```typescript // The third argument of request() — options await pokemonApiClient.request('getDetails', { id: 25 }, { disableCache: true, // bypass the cache timeout: 5000, // timeout for this request signal: abortController.signal, // abort signal headers: new Headers({ // extra headers 'X-Custom': 'value', }), context: { source: 'user' }, // passed into prepareHeaders }) // prepareHeaders receives the context: baseQuery: { baseUrl: 'https://pokeapi.co/api/v2', prepareHeaders: async (headers, context) => { headers.set('Accept', 'application/json') if (context.context?.source === 'admin') headers.set('X-Admin', 'true') // context.requestParams — the current request's params // context.getFromStorage('key') — read from storage // context.getCookie('name') — read a cookie return headers }, } ``` ## RequestDefinition — describing an endpoint's request ```typescript // The full structure of the object returned from request() request: (params) => ({ path: '/pokemon', // path (appended to baseUrl) method: 'GET', // 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' body: params, // request body (POST/PUT/PATCH) query: { limit: 12 }, // query params (?limit=12) headers: { 'X-Custom': '1' }, // headers for this request responseFormat: 'json', // 'json' | 'blob' | 'arrayBuffer' | 'text' | 'formData' | 'raw' }) // A mutation example (POST with body + cache invalidation): createPokemon: create<{ name: string; type: string }, PokemonApiResponse>({ request: (params) => ({ path: '/pokemon', method: 'POST', body: params, // serialized to JSON }), invalidatesTags: ['pokemon-list'], // on success, drops the list cache cache: false, // don't cache mutations }) ``` ## Caching and tags ```typescript // Global cache (for all endpoints) cache: { ttl: 60000, // 60 seconds invalidateOnError: true, } // Cache for a specific endpoint (overrides the global one) getList: create<...>({ cache: { ttl: 120000 }, // 2 minutes for the list }) // Disable cache for an endpoint createPokemon: create<...>({ cache: false, }) // Disable cache for a specific request await pokemonApiClient.request('getDetails', { id: 1 }, { disableCache: true, // forced network request }) // --- Tags --- // An endpoint is tagged: getList: create<...>({ tags: ['pokemon-list'], }) // A mutation invalidates tags on success: createPokemon: create<...>({ invalidatesTags: ['pokemon-list'], // drops the cache of all endpoints // tagged 'pokemon-list' }) ``` ## getEndpoints() — direct access to the endpoints This is the form the data layer hands to the effects: `pokemonApiClient.getEndpoints()` returns an object with typed endpoints (`getList`, `getDetails`), and the `PokemonApiEndpoints` type is passed into `PokemonEffects` through the constructor. ```typescript const endpoints = pokemonApiClient.getEndpoints() // The endpoint object: // { // fetchCounts: number — number of performed requests // request(params, options?) — perform a request (returns RequestResponseModify) // subscribe(callback) — subscribe to the endpoint state // reset() — reset the counter // meta: { name, tags, invalidatesTags, cache } // destroy() — cleanup // } // request() through an endpoint returns RequestResponseModify: const req = endpoints.getDetails.request({ id: 25 }) // RequestResponseModify: // { // id: string — the unique request ID // subscribe(listener) — subscribe to the request state // wait() — Promise // waitWithCallbacks({ idle, loading, success, error }) // abort() — abort the request // then/catch/finally — Promise API (awaitable) // } // Subscribe to the request state req.subscribe((state) => { // RequestState: // { // status: 'idle' | 'loading' | 'success' | 'error' // data?: PokemonApiResponse // error?: Error // fromCache: boolean // requestParams: { id: number } // } console.log(state.status) // 'loading' -> 'success' console.log(state.data) // PokemonApiResponse | undefined }) // Wait for the result const result = await req.wait() console.log(result.data) // PokemonApiResponse ``` > In the effects this same endpoint is wrapped in `fromRequest(this.api.getDetails.request(...))` — an > RxJS wrapper over `RequestResponseModify`. More in [Effects](./create-synapse-effects.md). ## waitWithCallbacks() — callbacks per status ```typescript const endpoints = pokemonApiClient.getEndpoints() const req = endpoints.getDetails.request({ id: 25 }) const result = await req.waitWithCallbacks({ idle: (state) => console.log('Idle'), loading: (state) => console.log('Loading...'), success: (data, state) => console.log('Data:', data), error: (error, state) => console.log('Error:', error), }) // result — the same QueryResult ``` ## abort() — aborting a request ```typescript // Way 1: through the endpoint const endpoints = pokemonApiClient.getEndpoints() const req = endpoints.getDetails.request({ id: 25 }) req.abort() // aborts the request via AbortController // Way 2: through an AbortController in the options const controller = new AbortController() const result = pokemonApiClient.request('getDetails', { id: 25 }, { signal: controller.signal, }) controller.abort() // aborts the request ``` ## subscribe() — subscribing to the endpoint state ```typescript const endpoints = pokemonApiClient.getEndpoints() // Subscribe to the overall endpoint state (not a specific request) const unsub = endpoints.getDetails.subscribe((state) => { // EndpointState: // { // status: 'idle' | 'loading' | 'success' | 'error' // error?: Error // fetchCounts: number // meta: { name, tags, invalidatesTags, cache } // cacheableHeaders: string[] // } console.log('Endpoint status:', state.status, 'requests:', state.fetchCounts) }) // Unsubscribe unsub() ``` ## Lifecycle ```typescript // Initialization (required before use). // In the module it's hidden inside initPokemonApi() and called from the createSynapse async factory: await initPokemonApi() // = pokemonApiClient.init() // Destruction (clears the cache, subscriptions, endpoints) await pokemonApiClient.destroy() ``` > **Where this comes together:** `pokemonApiClient` is created in `pokemon.api.ts`, initialized in the > factory's async prologue (`pokemon.synapse.ts`), and its endpoints are passed into `PokemonEffects`. > The full assembly is on the [Pokemon example](./pokemon-advanced.md) page. > **Using React?** The `ApiClient` is standalone (no RxJS/`createSynapse` required), but > `synapse-storage/react` ships thin hooks over the endpoints so you don't have to wire `request()` / > `subscribe()` by hand. They live on their own pages: **[useApiQuery](./api-use-query.md)** (GET) and > **[useApiMutation](./api-use-mutation.md)** (mutations). The sections below document the **native** > endpoint/client surface those hooks are built on. ## Cache invalidation bus: `endpoint.onCacheInvalidate()` When a mutation succeeds with `invalidatesTags`, the matching cache entries are removed **and** an invalidation event is emitted. Endpoints whose `tags` intersect the invalidated tags get notified — this is what powers `useApiQuery`'s auto-refetch (parity with React Query: after a mutation the active queries "come alive" instead of waiting for the TTL). ```typescript const endpoints = pokemonApiClient.getEndpoints() // Re-run something when the endpoint's cache is invalidated const unsub = endpoints.getList.onCacheInvalidate(() => { console.log('List cache invalidated — refetching') }) // Triggered, for example, by a mutation that invalidatesTags: ['PokemonList'] unsub() ``` ## Synchronous cache read: `endpoint.getCachedSync()` `getCachedSync(params)` reads a cached result **synchronously**, without a network request and without an async tick — so a hook can return server data on the very first render (no loading flash after hydration). It works only when: - the storage is synchronous (`MemoryStorage` / `LocalStorage`); - the endpoint has no headers that affect the cache key (the key can't be reproduced synchronously otherwise — headers are prepared async); - caching is enabled for the endpoint and the entry is not expired. In any other case it returns `undefined` and the caller falls back to the regular async `request()`. `useApiQuery` uses this automatically for its initial render. ```typescript const cached = endpoints.getDetails.getCachedSync({ id: 25 }) if (cached?.ok) console.log(cached.data) // instant, from cache ``` ## SSR: `dehydrate()` / `hydrate()` The client is **not limited to the browser** — the server path is real. On the server use `MemoryStorage`; on the client any storage will do. Cache timestamps are absolute (`expiresAt = now + ttl`), so they survive the server → client transfer, and the tag index is rebuilt on hydration. ```typescript // --- server --- const api = new ApiClient({ storage: () => new MemoryStorage({ name: 'api-cache' }), baseQuery, endpoints }) await api.init() await api.request('getList', { limit: 12, offset: 0 }) // warm up the cache const dehydrated = await api.dehydrate() // → serialize into HTML await api.destroy() // --- client --- const api = new ApiClient({ storage: () => new MemoryStorage({ name: 'api-cache' }), baseQuery, endpoints }) await api.hydrate(dehydrated) // BEFORE init → seeds the cache (init won't overwrite it) await api.init() // first request('getList', sameParams) hits the cache — no network call ``` - `dehydrate(): Promise` — a snapshot of the cache (symmetric to `dehydrateModule` for synapse modules). - `hydrate(state)` — seeds the cache. Called **before** `init()` it stashes the snapshot and applies it right after the storage is created (so `init()` won't overwrite the server state); called **after** `init()` it replaces the cache state and rebuilds the tag index immediately. - `getStorage()` — access to the underlying cache storage instance (manual SSR/debugging). > **Cache-key stability server ↔ client.** The cache key includes `cacheableHeaderKeys`. If the set of > cache-affecting headers differs between server and client (e.g. auth), the keys diverge and hydration > "misses". For SSR endpoints exclude such headers from the key via `excludeCacheableHeaderKeys`. --- # useApiQuery — React hook for GET requests A React Query–style hook over an `ApiClient` endpoint for **reading** data (GET). It is a thin layer on top of `endpoint.request(params).subscribe(...)` — deduplication, the tag cache, retry and abort already live in the endpoint (see [ApiClient](./api-client.md)). The hook adds the React part: subscription, a stable params key, `enabled`/`refetch`, an SSR fast-path (no loading flash) and auto-refetch on cache invalidation. The `ApiClient` is standalone (it depends neither on RxJS/`reactive` nor on `createSynapse`), so you can use just `synapse-storage/api` + `synapse-storage/core` + these hooks — without the whole state-manager. ## Import ```typescript import { useApiQuery } from 'synapse-storage/react' ``` ## Usage The hook takes an **endpoint** (from `getEndpoints()`), so `params`/`data` types are inferred from it. ```typescript const endpoints = pokemonApiClient.getEndpoints() function PokemonCard({ id }: { id: number }) { // GET: starts on mount, re-runs when params change const { data, isLoading, isError, error, refetch, fromCache } = useApiQuery(endpoints.getDetails, { id }) if (isLoading) return if (isError) return return (

{data?.name}

{fromCache && from cache}
) } ``` ## Return value `useApiQuery(endpoint, params, options?)` returns: | Field | Type | Description | |-------|------|-------------| | `data` | `TData \| undefined` | Response data (or cached data) | | `error` | `Error \| undefined` | Request error | | `status` | `'idle' \| 'loading' \| 'success' \| 'error'` | Current status | | `isLoading` | `boolean` | `status === 'loading'` | | `isError` | `boolean` | `status === 'error'` | | `isSuccess` | `boolean` | `status === 'success'` | | `fromCache` | `boolean` | Data came from cache rather than the network | | `refetch` | `() => void` | Force a re-request | ## Options Extends `QueryOptions` (`signal`, `headers`, `timeout`, `disableCache`, `retry`, …) plus: - **`enabled`** (default `true`) — when `false`, the request is not performed (lazy). Useful when params aren't ready yet: ```typescript // Won't fire until `id` is defined const { data } = useApiQuery(endpoints.getDetails, { id: id! }, { enabled: id != null }) ``` - **`refetchOnInvalidate`** (default `true`) — auto-refetch the active query after its cache tags get invalidated by a mutation (see [auto-refetch](#auto-refetch-on-cache-invalidation)). ## SSR: no loading flash after hydration The lazy initial state reads the cache **synchronously** via [`endpoint.getCachedSync()`](./api-client.md#synchronous-cache-read-endpointgetcachedsync). On the server `useEffect` doesn't run, so the very first (and only) render returns the seeded/cached data; on the client the first render after [hydration](./api-client.md#ssr-dehydrate-hydrate) shows the server data immediately instead of flashing `loading`. This works only for synchronous storages (`MemoryStorage`/`LocalStorage`) and endpoints without cache-affecting headers. Otherwise the hook falls back to the regular async path. ## Auto-refetch on cache invalidation When a mutation succeeds with `invalidatesTags`, the matching cache entries are removed and an invalidation event is emitted. An active `useApiQuery` whose endpoint `tags` intersect the invalidated tags **re-runs automatically** (parity with React Query — the query "comes alive" instead of waiting for the TTL). Under the hood this uses [`endpoint.onCacheInvalidate()`](./api-client.md#cache-invalidation-bus-endpointoncacheinvalidate). Turn it off with `refetchOnInvalidate: false`. ```typescript // getList endpoint: tags: ['PokemonList'] const list = useApiQuery(endpoints.getList, { limit: 12 }) // elsewhere — a mutation with invalidatesTags: ['PokemonList'] // → `list` refetches on its own ``` ## Notes - **Stable params key.** Params are serialized with sorted keys, so a fresh `{ id: 1 }` object on every render does **not** cause an infinite re-request. - **StrictMode-safe.** The effect aborts the in-flight request on cleanup. - **`params` identity doesn't matter.** You can pass an inline object literal — only the serialized key drives re-fetching. ## See also - [ApiClient](./api-client.md) — the native client, endpoints, caching and SSR. - [useApiMutation](./api-use-mutation.md) — the companion hook for writes. --- # useApiMutation — React hook for mutations A React hook over an `ApiClient` endpoint for **writes** (POST/PUT/DELETE/PATCH). Unlike [useApiQuery](./api-use-query.md), the request does **not** start automatically — you trigger it with `mutate`/`mutateAsync`. Mutations aren't cached (by REST method), and their `invalidatesTags` invalidate the cache — active `useApiQuery` hooks of neighbouring endpoints refetch on their own via the [invalidation bus](./api-client.md#cache-invalidation-bus-endpointoncacheinvalidate). ## Import ```typescript import { useApiMutation } from 'synapse-storage/react' ``` ## Usage ```typescript const endpoints = pokemonApiClient.getEndpoints() function CreatePokemon() { const { mutate, isLoading, isError, error } = useApiMutation(endpoints.createPokemon) return (
{ e.preventDefault() mutate({ name: 'Pikachu' }) // fire-and-forget }} > {isError && } ) } ``` ## Return value `useApiMutation(endpoint, options?)` returns: | Field | Type | Description | |-------|------|-------------| | `mutate` | `(params) => void` | Run the mutation; errors are **not** thrown (read `error`/`isError`) | | `mutateAsync` | `(params) => Promise` | Run and await; **rejects** on error | | `data` | `TData \| undefined` | Data of a successful response | | `error` | `Error \| undefined` | Mutation error | | `status` | `'idle' \| 'loading' \| 'success' \| 'error'` | Current status | | `isLoading` | `boolean` | `status === 'loading'` | | `isError` | `boolean` | `status === 'error'` | | `isSuccess` | `boolean` | `status === 'success'` | | `reset` | `() => void` | Reset state back to `idle` | `options` is `QueryOptions` (`signal`, `headers`, `timeout`, `retry`, …). ## mutate vs mutateAsync - **`mutate(params)`** — fire-and-forget. The rejection is swallowed (state already reflects the error), so you don't need a `.catch`. Best for simple form submits. - **`mutateAsync(params)`** — returns the promise and **rethrows** on error, so you can `await` and branch in flow: ```typescript const { mutateAsync } = useApiMutation(endpoints.createPokemon) async function onSubmit(values) { try { const res = await mutateAsync(values) navigate(`/pokemon/${res.data!.id}`) } catch (err) { toast.error(String(err)) } } ``` ## Invalidating related queries Give the mutation endpoint `invalidatesTags`; any `useApiQuery` whose endpoint `tags` intersect will refetch automatically. No manual cache wiring required. ```typescript // endpoint config createPokemon: create({ request: (body) => ({ path: '/pokemon', method: 'POST', body }), invalidatesTags: ['PokemonList'], }) // getList endpoint has tags: ['PokemonList'] // → after a successful createPokemon, an active useApiQuery(getList) refetches ``` ## Notes - **StrictMode-safe.** On unmount the in-flight request is aborted and state updates are skipped. - Mutations are never written to the cache (only GET is cached), so there's no `fromCache` here. ## See also - [useApiQuery](./api-use-query.md) — the companion hook for reads. - [ApiClient](./api-client.md) — caching, tags and the invalidation bus. --- # Pokémon SSR — server render + client pagination A practical recipe: fetch the **first page on the server**, ship the cache to the client, render it with no loading flash, and let **pagination continue on the client** from there. Built on the same Pokémon API as the [ApiClient](./api-client.md) and [useApiQuery](./api-use-query.md) pages. The `ApiClient` is server-safe: the network layer uses the global `fetch` (configurable via `fetchFn`), and every `window`/`document`/`localStorage` access is guarded by `typeof ... !== 'undefined'`. On the server use `MemoryStorage`; on the client any storage works (a sync one — `Memory`/`LocalStorage` — gives the instant first render). ## The idea The cache key is **endpoint name + params**, so each page (`offset`) is its own cache entry: - `offset: 0` was fetched on the server → it's in the cache → the first client render is instant; - `offset: 12` was not → cache miss → a normal network request **on the client**; - back to `offset: 0` (while the TTL holds) → instant from cache again. ## Shared API factory Use a **factory** (a fresh instance per server request — don't share one client across requests). The `storage` is itself a factory so each `init()` gets a clean store. ```typescript // pokemon.api.ts import { ApiClient } from 'synapse-storage/api' import { MemoryStorage } from 'synapse-storage/core' interface PokemonListApiResponse { count: number next: string | null results: Array<{ name: string; url: string }> } export function createPokemonApi() { return new ApiClient({ storage: () => new MemoryStorage>({ name: 'pokemon-api-cache', initialState: {} }), baseQuery: { baseUrl: 'https://pokeapi.co/api/v2', timeout: 10000 }, cache: { ttl: 120000 }, endpoints: async (create) => ({ getList: create<{ limit: number; offset: number }, PokemonListApiResponse>({ request: (params) => ({ path: '/pokemon', method: 'GET', query: params }), tags: ['pokemon-list'], }), }), }) } export type PokemonApiEndpoints = ReturnType['getEndpoints']> ``` ## Server: warm the cache and dehydrate ```tsx // server.tsx import { renderToString } from 'react-dom/server' import { createPokemonApi } from './pokemon.api' import { App } from './App' const PAGE_SIZE = 12 export async function renderApp() { const api = createPokemonApi() // per-request instance await api.init() // Warm the cache with the first page await api.request('getList', { limit: PAGE_SIZE, offset: 0 }) // Render: useApiQuery reads the cache synchronously → data lands in the HTML const html = renderToString() // Snapshot the cache for the client const state = await api.dehydrate() await api.destroy() return { html, state } } // In your HTML template, inline the snapshot: // ``` ## Client: hydrate and render ```tsx // client.tsx import { hydrateRoot } from 'react-dom/client' import { createPokemonApi } from './pokemon.api' import { App } from './App' async function bootstrap() { const api = createPokemonApi() await api.hydrate(window.__POKEMON_API_STATE__) // BEFORE init → seeds the cache await api.init() hydrateRoot(document.getElementById('root')!, ) } bootstrap() ``` ## The component: first page from cache, pagination on the client ```tsx // App.tsx import { useState } from 'react' import { useApiQuery } from 'synapse-storage/react' import type { PokemonApiEndpoints } from './pokemon.api' const PAGE_SIZE = 12 export function App({ endpoints }: { endpoints: PokemonApiEndpoints }) { const [offset, setOffset] = useState(0) // offset=0 → instant from the hydrated cache (no loading flash). // offset=12 → cache miss → network request on the client. const { data, isLoading, fromCache } = useApiQuery(endpoints.getList, { limit: PAGE_SIZE, offset }) const items = data?.results ?? [] const hasMore = !!data?.next return (
    {items.map((p) =>
  • {p.name}
  • )}
{isLoading && Loading…} {fromCache && from cache}
) } ``` The first page renders identically on the server and on the client's first paint (so React hydration matches — no mismatch warning). Clicking **Next** changes `offset`, which is a new cache key → the client fetches the next page; **Prev** returns to a page that's still cached → instant. ## Prewarming several pages Warm more than one page on the server — they all land in the snapshot: ```typescript await Promise.all([ api.request('getList', { limit: PAGE_SIZE, offset: 0 }), api.request('getList', { limit: PAGE_SIZE, offset: PAGE_SIZE }), ]) ``` Now `offset: 0` **and** `offset: 12` are instant on the client. ## Gotchas - **No loading flash needs a sync store on the client.** `useApiQuery`'s instant first render uses [`getCachedSync()`](./api-client.md#synchronous-cache-read-endpointgetcachedsync), which only works on `MemoryStorage`/`LocalStorage`. With `IndexedDB` the data still comes from the cache, but after one async tick (a brief `loading`). - **Cache-key stability server ↔ client.** The key includes `cacheableHeaderKeys`. If a cache-affecting header (e.g. auth) differs between server and client, the keys diverge and hydration "misses". Exclude such headers for SSR endpoints via `excludeCacheableHeaderKeys`. - **One instance per request on the server.** Never share a single client across requests — use the factory so each request gets its own cache. ## Next.js (App Router) Same flow: on the server (a Server Component or route handler) create the api with `MemoryStorage`, call `request()` to warm it, then `dehydrate()`. Pass the snapshot into a Client Component that calls `hydrate()` **before** `init()` and renders the hooks. (`dehydrate`/`hydrate` have no React dependency, so they're safe to call from `'server only'` code — mirrors `dehydrateModule` for synapse modules.) ## See also - [ApiClient](./api-client.md) — `dehydrate()`/`hydrate()`, `getCachedSync()`, caching and tags. - [useApiQuery](./api-use-query.md) — the hook used here. - [useApiMutation](./api-use-mutation.md) — writes + cache invalidation. --- # Custom fetch-intercepting ServiceWorker An **app-owned ServiceWorker** (Workbox-style) that transparently intercepts `fetch` and applies network strategies per URL: precache, offline routing, cache-first / network-first / stale-while-revalidate. It sits **below** `ApiClient`, which never knows it exists — and its `baseQuery.fetchFn` is **not** touched. ## When you need it Reach for your own SW when you need real control over the **network layer**, not just an in-tab cache: - **Precache** the app shell / static assets so the app boots with no network. - **Offline routing** — serve a cached response (or a synthesized fallback) when the network is gone. - **Per-URL strategies** — cache-first for immutable assets, network-first for fresh data, stale-while-revalidate for the rest. If instead you only need a **live cross-tab cache**, that is [`WorkerCacheStorage`](./worker-cache-storage.md). If you need to change **how** a request is performed (auth-retry, metrics, axios, worker transport), that is a [custom `baseQuery.fetchFn`](./custom-fetch-fn.md). ## How it works The SW is a separate script registered by the app. Once it controls the page, the browser routes **every** `fetch` (including the ones `ApiClient` makes internally) through the SW's `fetch` handler. Because interception happens **below** `fetch`, the request still reaches the network layer — so it still shows up in DevTools → Network, tagged `(ServiceWorker)`. `ApiClient` stays completely ordinary: plain storage, plain `baseQuery`, no custom `fetchFn`. The SW and the client are two independent layers. ## Registering the ServiceWorker ```typescript import { ApiClient } from 'synapse-storage/api' import { MemoryStorage } from 'synapse-storage/core' // A totally normal client — it does NOT know a ServiceWorker exists. const apiClient = new ApiClient({ storage: new MemoryStorage({ name: 'api-cache', initialState: {} }), baseQuery: { baseUrl: 'https://pokeapi.co/api/v2', timeout: 10000 }, endpoints: async (create) => ({ getPokemon: create({ request: ({ id }) => ({ path: `/pokemon/${id}`, method: 'GET' }), cache: true, }), }), }) // Register the app-owned SW separately. This is the ONLY wiring it needs. if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/custom-sw.js') } ``` ## The ServiceWorker script The SW is a hand-written static asset (served from `public/custom-sw.js`) that you author and own — synapse does not generate it for you. Precache on install, stale-while-revalidate per URL, offline fallback: ```javascript const CACHE = 'custom-fetch-demo-v1' const PRECACHE_URLS = ['/', '/index.html'] const SWR_HOST = 'pokeapi.co' self.addEventListener('install', (event) => { self.skipWaiting() event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE_URLS).catch(() => undefined))) }) self.addEventListener('activate', (event) => { event.waitUntil(self.clients.claim()) }) self.addEventListener('fetch', (event) => { if (event.request.method !== 'GET') return const url = new URL(event.request.url) if (url.hostname === SWR_HOST) event.respondWith(staleWhileRevalidate(event.request)) }) async function staleWhileRevalidate(request) { const cache = await caches.open(CACHE) const cached = await cache.match(request) const network = fetch(request) .then((res) => { if (res && res.ok) cache.put(request, res.clone()) return res }) .catch(() => null) if (cached) return cached // serve stale, revalidate in background return (await network) ?? offlineFallback(request) } ``` > **Tip.** `skipWaiting()` + `clients.claim()` let the SW start controlling open tabs immediately. > Without them, a freshly registered SW only controls the page after the next reload. ## How it differs from WorkerCacheStorage They solve **different** problems and act at **different** layers: - [`WorkerCacheStorage`](./worker-cache-storage.md) is a **storage backend** for `ApiClient`. On a cache hit the client short-circuits **above** `fetch` — the request never happens, so there is **no** Network row at all. It is a live cross-tab cache, not an offline layer. - The custom SW is a **network interceptor**. It sits **below** `fetch`, so the request still shows up in Network (tagged `(ServiceWorker)`), and it can serve responses fully offline. > **Warning.** The SW is unrelated to `ApiClient.storage`. The storage is the client's in-tab > cache/tag layer; the SW is a platform-level network interceptor. Configuring one does not affect > the other. ## Network behavior Where a response comes from is directly observable in DevTools → Network: | Source | Network row | | ---------------------------------------- | --------------------------- | | `WorkerCacheStorage` cache hit | **no row** (fetch skipped) | | App-owned SW cache / offline hit | row tagged `(ServiceWorker)`| | Browser HTTP cache (`Cache-Control`) | row tagged `(from disk cache)` | | Cold network fetch | normal row | ## When to use - **Offline / network control** (precache, offline routing, per-URL strategies) → your own SW, as here. No library changes. - **Live cross-tab cache** (shared state held in a worker) → [`WorkerCacheStorage`](./worker-cache-storage.md). - **Replace the transport** (auth-retry, metrics, axios, worker) → [custom `baseQuery.fetchFn`](./custom-fetch-fn.md). --- # Custom baseQuery.fetchFn `ApiClient` accepts a custom transport through `baseQuery.fetchFn?: typeof fetch`. It replaces **how** the client performs a request — an axios wrapper, an auth-retry, a metrics probe, or a `postMessage` bridge to a Web Worker for heavy parsing. This is the **transport**, not a ServiceWorker interception (for that, see [Custom fetch-intercepting ServiceWorker](./custom-fetch-service-worker.md)). > **The library does NOT need extending.** `fetchFn` is a built-in field of `baseQuery`. You never > touch the library source — you pass your own function and the client uses it verbatim. ## When it makes sense - **Auth-retry** — attach an `Authorization` header and do a single silent `refresh → retry` when the server answers `401`, so the rest of the app never sees the expiry. - **Metrics / tracing** — time every request, inject a trace id, report failures. - **axios (or any client)** — reuse an existing axios instance, interceptors and all, by adapting its response back into a standard `Response`. - **Worker transport** — offload `fetch` + heavy parsing to a `Worker` via `postMessage` and return a ready `Response`, keeping the main thread free. ## Configuration ```typescript import { ApiClient } from 'synapse-storage/api' import { MemoryStorage } from 'synapse-storage/core' // A custom fetchFn matches the `typeof fetch` signature exactly. const customFetch: typeof fetch = async (input, init) => { // ...do whatever the transport needs, then return a Response return fetch(input, init) } const client = new ApiClient({ storage: new MemoryStorage({ name: 'api-cache', initialState: {} }), baseQuery: { baseUrl: 'https://api.example.com', fetchFn: customFetch, // ← replaces the transport; everything else is unchanged }, cache: { ttl: 60_000 }, endpoints: async (create) => ({ getNotes: create({ request: () => ({ path: '/notes', method: 'GET' }), tags: ['notes'] }), }), }) ``` `fetchFn` must satisfy `typeof fetch`: `(input: RequestInfo | URL, init?: RequestInit) => Promise`. The client calls it with the fully-built URL and `RequestInit` (method, headers, serialized body, `signal`, `credentials`), and reads the returned `Response` exactly as it would a native one. ## Auth-retry example A `fetchFn` that adds a bearer token and does exactly one silent refresh-then-retry on `401`: ```typescript let sessionToken = getToken() const customFetch: typeof fetch = async (input, init) => { const send = (token: string) => { const headers = new Headers(init?.headers) headers.set('Authorization', `Bearer ${token}`) return fetch(input, { ...init, headers }) } let res = await send(sessionToken) if (res.status === 401) { // The ApiClient above never sees the 401 — it only gets the retried result. sessionToken = await refreshToken() res = await send(sessionToken) } return res } ``` The `401` handling lives entirely in the transport. The `ApiClient` — its endpoints, cache and tags — is oblivious to the retry: it hands off a request and gets back a successful `Response`. ## Cache and tags work on top The custom transport sits **below** the cache/tags layer, so caching, TTL, tags and tag-invalidation keep working with no changes: ```typescript endpoints: async (create) => ({ // GET — cached and tagged getNotes: create({ request: () => ({ path: '/notes', method: 'GET' }), cache: true, tags: ['notes'] }), // POST — invalidates the tag on success addNote: create({ request: (body) => ({ path: '/notes', method: 'POST', body }), cache: false, invalidatesTags: ['notes'], }), }) await client.request('getNotes', {}) // → runs through customFetch await client.request('getNotes', {}) // → served from cache, customFetch is NOT called await client.request('addNote', { title: 'x' }) // → invalidates 'notes' await client.request('getNotes', {}) // → refetches through customFetch again ``` > **The cache short-circuits above the transport.** A cache hit never reaches `fetchFn` — there is no > transport call at all. `fetchFn` only runs on a cache miss or an invalidated tag. ## No library extension needed Both fetch-control axes are available without patching `synapse-storage`: - **Transport swap** (this page) → `baseQuery.fetchFn`, built in. - **Transparent `fetch` interception** → your own [ServiceWorker](./custom-fetch-service-worker.md), which lives below the client and is unrelated to its storage. ## When to use - Need to change **how** a request is sent (auth, retries, metrics, axios, worker) → `fetchFn`. - Need to intercept `fetch` transparently for precache / offline / URL strategies → a [custom ServiceWorker](./custom-fetch-service-worker.md), not `fetchFn`. - Need a live cross-tab response cache → [`WorkerCacheStorage`](./worker-cache-storage.md), a storage backend, unrelated to the transport. --- # Forms — the recipe: form state on a synapse storage Managing forms is the most common application case. This page is a **copy-paste recipe**: take the code, drop it into your project, and you get your own form management instead of `react-hook-form` / `Formik` / `Final Form` — **zero dependencies**. Why do it on a synapse storage at all? Because a storage gives you, **by architecture**, things a form library bolts on from the side: - **SSR + hydration** — the form renders on the server with values/errors already filled in, and the client picks it up without a flash (see [SSR hydration](./ssr-hydration.md)). - **Cross-tab sync** — `syncBroadcastMiddleware` syncs the form draft between tabs. - **Persist + migrations** — `LocalStorage` / `IndexedDB` keep the draft between sessions, `version` + `migrate` migrate the draft schema (see [Persist migrations](./persist-migration.md)). - **Validation as a middleware** — centralized, reusable, runs on **any** write to the store regardless of the source (see [Middlewares](./middlewares.md)). The recipe is built as a ladder: copy exactly the level you need. ## Honest scope — what this is and isn't This is **not** "react-hook-form but better at everything". What we reproduce vs. do differently: **Covered:** form state `{ values, errors, touched, isValid, isSubmitting, submitCount }`; per-field re-render isolation via `useStorageSubscribe`; synchronous schema validation as a middleware; `touched` / `isValid`; submit flow; `reset()`. **Different / not out of the box (on purpose):** - No field registration à la `register('email')` for native inputs — we use controlled inputs + `set`/`update`. - Async validation (server-side checks) is a separate, more involved pattern (via effects/BLL or by hand); the base recipe is synchronous. - Array / dynamic fields are shown briefly — they are a complication. - Performance on huge forms is fine thanks to point-wise subscriptions, but **measure**, don't assume. This is a recipe, not a mini-library: there is no public form API to import — you own the code. ## State shape ```typescript type FormErrors = Partial> interface FormState> { values: V errors: FormErrors touched: Partial> isValid: boolean isSubmitting: boolean submitCount: number } ``` We use a sign-up form as the running example: ```typescript interface SignUp { email: string password: string confirm: string } const makeInitial = (): FormState => ({ values: { email: '', password: '', confirm: '' }, errors: {}, touched: {}, isValid: false, isSubmitting: false, submitCount: 0, }) ``` ## Writing a field Write each field by its path. Two tiny helpers keep the call sites tidy: ```typescript import { ISyncStorage } from 'synapse-storage/core' function setField>(storage: ISyncStorage>, name: keyof V & string, value: V[keyof V]) { storage.set(`values.${name}`, value) } function touchField>(storage: ISyncStorage>, name: keyof V & string) { storage.set(`touched.${name}`, true) } ``` `set` writes **immutably** — it never mutates your `initialState`, so `reset()` always restores the original values — and it updates `getStateSync()` **synchronously**, so the SSR snapshot and `useStorageSubscribe` see fresh errors immediately. Re-render isolation does **not** suffer: a field selector returns a primitive that doesn't change by reference for untouched fields, so `useStorageSubscribe` with `equals` skips their re-render. ## Level 1 — a basic form (MemoryStorage) A bare skeleton: controlled inputs, point-wise reads, submit, reset. No validation yet. ```tsx import { MemoryStorage, ISyncStorage } from 'synapse-storage/core' import { useStorageSubscribe } from 'synapse-storage/react' import { useMemo, useEffect, useState, FormEvent } from 'react' function useFormStorage() { const [storage] = useState>>(() => new MemoryStorage>({ name: 'sign-up', initialState: makeInitial() })) const [ready, setReady] = useState(false) useEffect(() => { let alive = true storage.initialize().then(() => alive && setReady(true)) return () => { alive = false storage.destroy() } }, [storage]) return { storage, ready } } // A single field — re-renders ONLY when its own value/error changes. function Field({ storage, name, type = 'text' }: { storage: ISyncStorage>; name: keyof SignUp; type?: string }) { const value = useStorageSubscribe(storage, (s) => s.values[name] ?? '', { equals: Object.is }) const error = useStorageSubscribe(storage, (s) => (s.touched[name] ? s.errors[name] : undefined), { equals: Object.is }) return ( ) } export function SignUpForm() { const { storage, ready } = useFormStorage() if (!ready) return null const onSubmit = (e: FormEvent) => { e.preventDefault() const { values } = storage.getState() console.log('submit', values) } return (
) } ``` The key win is already here: `useStorageSubscribe(storage, s => s.values[name], { equals })` subscribes the component to **one** field — typing in `email` does not re-render `password`. More on reads in [Subscriptions](./subscriptions.md). ## Level 2 — validation as a middleware Validation as a middleware is **centralized**: it runs on every write to the store, no matter who wrote it. The schema is a plain function — zero dependencies. ```typescript import { SyncMiddleware, StorageAction } from 'synapse-storage/core' type Validator = (values: V) => FormErrors const shallowEqualErrors = (a: Record, b: Record) => { const ak = Object.keys(a) const bk = Object.keys(b) return ak.length === bk.length && ak.every((k) => a[k] === b[k]) } // Validate ONLY when something inside `values.*` changes (guard, see point #2 below). const touchesValues = (action: StorageAction) => { if (action.type === 'set') return String(action.key ?? '').split('.')[0] === 'values' if (action.type === 'update') return (action.metadata?.changedPaths ?? []).some((p: string) => String(p).split('.')[0] === 'values') return false } export function createFormValidationMiddleware>(validate: Validator): SyncMiddleware { return { name: 'form-validation', reducer: (api) => (next) => (action) => { // 1) Write the value FIRST — never block invalid input. const result = next(action) if (!touchesValues(action)) return result // 2) Compute errors from the already-written state. const state = api.getState() as FormState const errors = validate(state.values) const isValid = Object.keys(errors).length === 0 if (shallowEqualErrors(state.errors ?? {}, errors) && state.isValid === isValid) return result // 3) Write derived state DIRECTLY (bypassing dispatch) → no recursive validation. api.storage.doSet('errors', errors) api.storage.doSet('isValid', isValid) // 4) Notify point-wise subscribers and subscribeToAll / useStorageSubscribe. api.storage.notifySubscribers('errors', errors) api.storage.notifySubscribers('isValid', isValid) api.storage.notifySubscribers('*', { type: 'storage:update', key: ['errors', 'isValid'], value: { errors, isValid }, changedPaths: ['errors', 'isValid'] }) return result }, } } ``` The schema for the sign-up form: ```typescript const validateSignUp: Validator = (v) => { const errors: FormErrors = {} if (!v.email) errors.email = 'Email is required' else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(v.email)) errors.email = 'Email is invalid' if (v.password.length < 6) errors.password = 'Min 6 characters' if (v.confirm !== v.password) errors.confirm = 'Passwords do not match' return errors } ``` Wire it into the storage: ```typescript const storage = new MemoryStorage>({ name: 'sign-up', initialState: makeInitial(), middlewares: () => [createFormValidationMiddleware(validateSignUp)], }) ``` That's it — `Field` from level 1 already reads `errors[name]`, so errors light up live. ### The three gotchas of a validation middleware These are the traps that make a naive validation middleware misbehave — the recipe above already handles all three: 1. **Don't block writing an invalid value.** The input must show what the user typed. So the middleware runs `next(action)` **first** (writes the value), then computes errors. Returning the `VALUE_NOT_CHANGED` sentinel on invalid input would make the field "stick". 2. **No recursion.** Writing `errors` inside the middleware must not re-trigger validation. We solve it two ways at once: write `errors` via `api.storage.doSet` + `notifySubscribers` **directly** (bypassing the dispatch chain), **and** only validate when a `values.*` key changes (`touchesValues` guard). 3. **When to validate.** Live on every write here, but the UI shows an error only for `touched` fields (see `Field`). Validate-on-blur or validate-on-submit are variations of the same guard. ### Alternative: validation via a selector (derived state) You can compute errors as **derived state** with a selector instead of a middleware: `errors = createSelector(values => validate(values))` (see [Selectors](./selector-system.md)). - **Selector** — simpler, no recursion to worry about. But errors are not part of the persisted / shared state, and there is no single point that forces validation on every writer. - **Middleware** — pick it when you need centralization, or to **persist / share** errors as part of the state (e.g. across tabs). This recipe uses the middleware as the primary approach. ## Level 3 — draft persistence + cross-tab sync Turn the form into a draft that survives reloads and stays in sync across tabs. Swap `MemoryStorage` for `LocalStorage` and add `syncBroadcastMiddleware`: ```typescript import { LocalStorage, syncBroadcastMiddleware } from 'synapse-storage/core' const storage = new LocalStorage>({ name: 'sign-up', initialState: makeInitial(), version: 1, // bump when the form shape changes (see persist-migration) middlewares: () => [ createFormValidationMiddleware(validateSignUp), syncBroadcastMiddleware({ storageType: 'localStorage', storageName: 'sign-up' }), ], }) ``` - **Persist** — `LocalStorage` saves the draft automatically; on the next visit the user continues where they left off. - **Cross-tab** — `syncBroadcastMiddleware` broadcasts `set`/`update` to other tabs over the `localStorage-sign-up` channel; the same draft is mirrored everywhere. - **Migrations** — if the form shape changes between releases, bump `version` and add `migrate` so an old draft is converted instead of breaking. See [Persist migrations](./persist-migration.md). > Order matters: keep validation **before** broadcast so other tabs receive an already-validated > state. For drafts you usually don't want to wipe storage on unmount — set `clearOnDestroy: false` (the `LocalStorage` default) so the draft survives. ## Level 4 — SSR (server-rendered form) The form can render on the server with values/errors already in place, then hydrate on the client with no flash. The same pattern as [SSR hydration](./ssr-hydration.md): call `hydrate(snapshot)` **before** `initialize()` so the server snapshot is not overwritten by `initialState`. ```typescript // On the server: build a snapshot (e.g. from a failed POST with field errors) and serialize it. const serverState: FormState = { ...makeInitial(), values: { email: 'taken@example.com', password: '', confirm: '' }, errors: { email: 'This email is already registered' }, touched: { email: true }, } // → embed JSON.stringify(serverState) into the HTML. ``` ```typescript // On the client: seed the storage from the embedded snapshot BEFORE initialize(). const storage = new MemoryStorage>({ name: 'sign-up', initialState: makeInitial(), middlewares: () => [createFormValidationMiddleware(validateSignUp)], }) storage.hydrate(window.__FORM_STATE__) // BEFORE initialize() → initialState won't clobber it await storage.initialize() ``` `getStateSync()` gives you the snapshot to serialize; `hydrate()` called **after** `initialize()` replaces the state and notifies subscribers. At module level there is `dehydrateModule()` and the React wrapper `createSynapseCtx` — reuse the same pattern when the form is part of a larger module. ## Submit flow `isSubmitting` blocks a double submit; on failure you can push server errors into the same `errors` slice: ```typescript async function submit(storage: ISyncStorage>, send: (values: SignUp) => Promise) { const state = storage.getState() if (state.isSubmitting) return // guard against double submit if (!state.isValid) { // mark everything touched so all errors show storage.set('touched', Object.fromEntries(Object.keys(state.values).map((k) => [k, true]))) return } storage.update((s) => { s.isSubmitting = true s.submitCount += 1 }) try { await send(state.values) storage.reset() // success → back to initialState } catch (e: any) { storage.set('errors', { email: e?.message ?? 'Submit failed' }) } finally { storage.set('isSubmitting', false) } } ``` ## Dynamic / array fields (brief) Arrays live in `values` like everything else; replace the array by its path on edits: ```typescript function addItem(storage: ISyncStorage>, key: string, item: unknown) { const list = storage.getState().values[key] ?? [] storage.set(`values.${key}`, [...list, item]) } ``` Validation stays the same — the middleware re-validates the whole `values` on each change. ## See also - [Middlewares](./middlewares.md) — the middleware API used here - [Subscriptions](./subscriptions.md) · [Selectors](./selector-system.md) — point-wise reads and the selector-based validation alternative - [Persist migrations](./persist-migration.md) — draft persistence + schema migrations - [SSR hydration](./ssr-hydration.md) — server-rendered form - [Pokemon Advanced](./pokemon-advanced.md) — the reference recipe for a full module --- # Pokemon Advanced — the recipe: the whole data layer on PokeAPI The final page of the chain. Every previous section dissected one brick on this same domain — here they come together into **one working module**: ApiClient with caching → mappers → storage → selectors → dispatcher → effects → `createSynapse` → React. This is the reference for how to split a data-management layer into responsibility files and copy it into your own project. Each section below links to the page where the corresponding brick is covered in detail. ## Module structure The whole domain lives in a single `pokemon-advanced/` folder, one file = one responsibility: ``` pokemon-advanced/ pokemon.types.ts — domain types + request-state shape pokemon.store.ts — initialState (store shape) pokemon.settings.ts — external settings storage (a dependency) pokemon.api.ts — ApiClient (endpoints, cache) + response mappers pokemon.selectors.ts — derived values (class Selectors) pokemon.dispatcher.ts — intents (class Dispatcher) pokemon.effects.ts — side-effects on RxJS (class Effects) pokemon.synapse.ts — assembly via createSynapse(factory) index.ts — public exports PokemonAdvancedExample.tsx / PokemonDemo.tsx — UI on top of the synapse helpers.ts — small presentation utilities (typeColor) ``` ## Data flow ``` UI (PokemonDemo) │ store.actions.loadList() / selectPokemon(id) / setSearchQuery(q) / toggleFavorite(id) ▼ dispatcher (intents) ──► action$ ──► effects (RxJS) │ apiActions/action/signal │ ofType → validateMap → fromRequest(api) │ ▼ │ pokemon.api.ts (ApiClient + mappers) │ │ apiResult(data) → mapListResponse / mapDetailsResponse ▼ ▼ └──────────► applyPokemonList / applyPokemonDetails / loadList.success ──► storage │ selectors (filteredList, isLoading…) ◄┘ │ useSelector ▼ UI ``` The flow is one-way: the UI sends **intents** to the dispatcher, effects do side-effects and write the result to storage via actions, and selectors hand derived values back to the UI. ## 1. Types and state shape — `pokemon.types.ts` State holds both domain data and the **request protocol** (`api.listRequest`/`detailsRequest` with a status). More on the request-state shape in [create-synapse-effects](./create-synapse-effects.md). ```typescript export type ApiStatus = 'idle' | 'loading' | 'success' | 'error' | 'reset' export interface ApiRequestState { status: ApiStatus error: string | null } export interface PokemonState { api: { listRequest: ApiRequestState detailsRequest: ApiRequestState } pokemonList: PokemonBrief[] offset: number hasMore: boolean selectedPokemonId: number | null selectedPokemon: PokemonDetails | null searchQuery: string favorites: number[] } ``` `pokemon.store.ts` next to it is just `initialState: PokemonState` (both requests `'idle'`, lists empty). ## 2. ApiClient + mappers — `pokemon.api.ts` → in detail: [api-client](./api-client.md) An ApiClient with tag-based caching and two endpoints (`getList`/`getDetails`). The raw PokeAPI response types don't leak into the domain — mappers unfold them. ```typescript export const pokemonApiClient = new ApiClient({ storage: new MemoryStorage>({ name: 'pokemon-advanced-api-cache', initialState: {} }), baseQuery: { baseUrl: 'https://pokeapi.co/api/v2', timeout: 10000 }, cache: { ttl: 60000, invalidateOnError: true }, endpoints: async (create) => ({ getList: create<{ limit: number; offset: number }, PokemonListApiResponse>({ request: (params) => ({ path: '/pokemon', method: 'GET', query: params }), cache: { ttl: 120000 }, tags: ['pokemon-list'], }), getDetails: create<{ id: number }, PokemonApiResponse>({ request: ({ id }) => ({ path: `/pokemon/${id}`, method: 'GET' }), cache: true, tags: ['pokemon-details'], }), }), }) export const initPokemonApi = () => pokemonApiClient.init() export type PokemonApiEndpoints = ReturnType // Mappers: raw response → domain type (id from url, sprite by id, flat stats/abilities) export function mapListResponse(data: PokemonListApiResponse): { list: PokemonBrief[]; hasMore: boolean } { /* … */ } export function mapDetailsResponse(data: PokemonApiResponse): PokemonDetails { /* … */ } ``` ## 3. External settings — `pokemon.settings.ts` → in detail: [dependencies](./dependencies.md) A separate raw storage: something the module depends on but doesn't own (here — the page size). In the synapse it comes in as a dependency. ```typescript export const settingsStorage = new MemoryStorage({ name: 'pokemon-settings', initialState: { pageSize: 12 }, }) ``` ## 4. Selectors — `pokemon.selectors.ts` → in detail: [create-synapse-basic](./create-synapse-basic.md), [selector-system](./selector-system.md) Derived values. The intermediate `api` slice is `private` (not visible outside, but works as a dependency in `combine`). `filteredList` = `pokemonList` × `searchQuery`. ```typescript export class PokemonSelectors extends Selectors { private readonly api = this.select((s) => s.api) readonly pokemonList = this.select((s) => s.pokemonList) readonly searchQuery = this.select((s) => s.searchQuery) readonly favorites = this.select((s) => s.favorites) readonly listStatus = this.combine([this.api], (a) => a.listRequest.status) readonly isListLoading = this.combine([this.listStatus], (s) => s === 'loading') readonly filteredList = this.combine([this.pokemonList, this.searchQuery], (list, query) => query ? list.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : list, ) readonly favoriteCount = this.combine([this.favorites], (favs) => favs.length) } ``` ## 5. Dispatcher — `pokemon.dispatcher.ts` → in detail: [create-synapse-dispatcher](./create-synapse-dispatcher.md), [dispatcher-detailed](./dispatcher-detailed.md) Intents. `apiActions` unfolds into the request lifecycle in a single field; `action` is a synchronous write (payload = return); `signal` is a pure intent; `watcher` is a reactive observer. ```typescript export class PokemonDispatcher extends Dispatcher { readonly loadList = this.apiActions((s) => s.api.listRequest) // init/loading/success/failure/reset readonly loadDetails = this.apiActions((s) => s.api.detailsRequest) readonly loadMore = this.signal('Load the next page') readonly selectPokemon = this.action((store, id: number | null) => { /* update selectedId */ return id }) readonly applyPokemonList = this.action((store, data: { list: PokemonBrief[]; hasMore: boolean; append: boolean }) => /* … */) readonly applyPokemonDetails = this.action((store, details: PokemonDetails) => /* … */) readonly setSearchQuery = this.action((store, query: string) => { store.set('searchQuery', query); return query }) readonly toggleFavorite = this.action((store, id: number) => { /* toggle in favorites */ return id }) readonly watchFavoriteCount = this.watcher({ selector: (s) => s.favorites.length, notifyAfterSubscribe: true }) } ``` > `ofType(d.loadList)` in an effect catches ONLY init. To react to the result — `ofType(d.loadList.success)`. ## 6. Effects — `pokemon.effects.ts` → in detail: [create-synapse-effects](./create-synapse-effects.md) Side-effects per action. Services (API endpoints) and the external store (`settings$`) arrive **through the constructor** and are captured in a closure — the effect doesn't reach into the global scope for them. ```typescript export class PokemonEffects extends Effects { constructor( private readonly api: PokemonApiEndpoints, private readonly settings$: Observable, ) { super() } readonly loadList = this.effect((action$, state$, { dispatcher: d }) => action$.pipe( ofType(d.loadList), // init only withLatestFrom(selectorObject(state$, { listStatus: (s) => s.api.listRequest.status }), this.settings$), validateMap({ 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() }), ), }), ), ) // loadMore — the same, but offset from the store + append: true; loadDetails — ofType(selectPokemon) → getDetails. } ``` ## 7. Assembly — `pokemon.synapse.ts` → in detail: [create-synapse-basic](./create-synapse-basic.md), [dependencies](./dependencies.md) `createSynapse(factory)` ties everything together. The factory is **async** — it has an `initPokemonApi()` prologue. It returns a lazy handle: the factory starts on the first `await`/`ready()`, not on import. ```typescript export const pokemonSynapse = createSynapse(async () => { await initPokemonApi() // async prologue const storage = new MemoryStorage({ name: 'pokemon-advanced', initialState }) return { storage, dependencies: [settingsStorage], // dependency on the settings store dependencyTimeout: 10000, dispatcher: new PokemonDispatcher(storage), selectors: new PokemonSelectors(storage), effects: new PokemonEffects(pokemonApiClient.getEndpoints(), toObservable(settingsStorage)), } }) export type PokemonSynapse = Awaited ``` ## 8. React — `PokemonAdvancedExample.tsx` + `PokemonDemo.tsx` → in detail: [await-synapse](./await-synapse.md) (manual lift), [synapse-ctx](./synapse-ctx.md) (via provider) `pokemonSynapse` from step 7 is a **lazy handle** (essentially a `Promise` of the ready module), so in React you first have to *await* it: `loadingComponent` stays on screen while storage initializes. Three working approaches below — pick per use case. All copy-paste as-is; you only need `pokemonSynapse` and `PokemonDemo`. **Option A — HOC `withSynapseReady` (as in the repo example).** The awaiter is created once at module level; the HOC keeps `loadingComponent` until storage is ready, then hands the store over synchronously — inside, `getStoreIfReady()!` is guaranteed non-`undefined`: ```typescript import { useEffect } from 'react' import { awaitSynapse } from 'synapse-storage/react' import { pokemonSynapse } from './pokemon.synapse' import { PokemonDemo } from './PokemonDemo' const pokemonAwaiter = awaitSynapse(pokemonSynapse, { loadingComponent:
Initializing…
, errorComponent: (error) =>
Init failed: {error.message}
, }) function PokemonContent() { const store = pokemonAwaiter.getStoreIfReady()! // ready — available synchronously useEffect(() => { store.actions.loadList() }, [store]) // initial load return } export const PokemonAdvancedExample = pokemonAwaiter.withSynapseReady(PokemonContent) ``` **Option B — `useSynapseReady` hook.** No HOC: gate loading/error right in the component, `store` comes from the hook (`undefined` until ready): ```typescript const pokemonAwaiter = awaitSynapse(pokemonSynapse) export function PokemonAdvancedExample() { const { isPending, isError, error, store } = pokemonAwaiter.useSynapseReady() useEffect(() => { store?.actions.loadList() }, [store]) if (isError) return
Error: {error?.message}
if (isPending || !store) return
Initializing…
return } ``` **Option C — `createSynapseCtx` provider.** When the store is needed in deeply nested components without prop drilling. Wrap the tree once, children pull `selectors` / `actions` / `storage` / `state$` from context hooks: ```typescript import { useEffect } from 'react' import { createSynapseCtx, useSelector } from 'synapse-storage/react' import { pokemonSynapse } from './pokemon.synapse' const pokemonCtx = createSynapseCtx(pokemonSynapse, { loadingComponent:
Initializing…
, }) // The component knows nothing about module creation — it only consumes it from context. function PokemonPanel() { const selectors = pokemonCtx.useSynapseSelectors() // = store.selectors const actions = pokemonCtx.useSynapseActions() // = store.actions const list = useSelector(selectors.filteredList) const query = useSelector(selectors.searchQuery) useEffect(() => { actions.loadList() }, [actions]) return actions.setSearchQuery(e.target.value)} /* …UI… */ /> } // contextSynapse lifts the module and wraps the component in a Provider. export const PokemonAdvancedExample = pokemonCtx.contextSynapse(PokemonPanel) ``` Inside `PokemonDemo` reads/writes are identical across all options: read through `useSelector(store.selectors.X)` (from `synapse-storage/react`), send intents through `store.actions.X(...)`, and wire up `watchFavoriteCount` via `store.dispatcher.watchers.watchFavoriteCount()`. ## The 5-state request protocol The core of the dispatcher↔effects link: every request goes through a fixed lifecycle, and the UI reads it through `status` selectors. ``` UI dispatch (loadList) -> status = 'idle' (no UI change) | effect: validateMap |-- validation OK -> loadingAction -> status = 'loading' (spinner) | |-- API OK -> apiResult(success) -> status = 'success' (data) | \-- API ERR -> errorAction -> status = 'error' (error) \-- validation FAIL -> skipAction -> status = 'reset' (no UI flicker) ``` ## Map: capability → page | Capability | Module file | Page | |---|---|---| | ApiClient (cache/tags), mappers | `pokemon.api.ts` | [api-client](./api-client.md) | | storage + selectors, minimal createSynapse | `pokemon.store.ts`, `pokemon.selectors.ts` | [create-synapse-basic](./create-synapse-basic.md) | | dispatcher (action/signal/apiActions/watcher) | `pokemon.dispatcher.ts` | [create-synapse-dispatcher](./create-synapse-dispatcher.md), [dispatcher-detailed](./dispatcher-detailed.md) | | effects (validateMap/apiResult/fromRequest) | `pokemon.effects.ts` | [create-synapse-effects](./create-synapse-effects.md) | | dependencies (settingsStorage, async factory) | `pokemon.settings.ts`, `pokemon.synapse.ts` | [dependencies](./dependencies.md) | | React: manual lift / provider | `PokemonAdvancedExample.tsx` | [await-synapse](./await-synapse.md), [synapse-ctx](./synapse-ctx.md) | | framework-independent awaiter, SSR fast-path | — | [synapse-awaiter](./synapse-awaiter.md) | | event bus between modules | — | [event-bus](./event-bus.md) | ``` ---