synapse-storage
Version:
Набор инструментов для управления состоянием и апи-запросами
202 lines (161 loc) • 10.7 kB
Markdown
# Synapse Storage
[](https://badge.fury.io/js/synapse-storage)
[](https://bundlephobia.com/package/synapse-storage)
[](https://www.typescriptlang.org/)
[](https://rxjs.dev/)
Framework-agnostic state management toolkit and API client for TypeScript applications.
Combines reactive storage, memoized selectors, Redux-Observable style effects, and a tag-based HTTP cache — all in one library.
## Quick Start
```bash
npm install synapse-storage
```
```typescript
import { MemoryStorage, Selectors } from 'synapse-storage/core'
import { Dispatcher } from 'synapse-storage/reactive'
import { createSynapse } from 'synapse-storage/utils'
class CounterDispatcher extends Dispatcher<{ count: number }> {
inc = this.action((store) => store.update((s) => { s.count++ }))
}
class CounterSelectors extends Selectors<{ count: number }> {
count = this.select((s) => s.count)
}
export const counter = createSynapse({
storage: () => new MemoryStorage({ name: 'counter', initialState: { count: 0 } }),
dispatcher: (s) => new CounterDispatcher(s),
selectors: (s) => new CounterSelectors(s),
})
```
> **Two independent layers.** `synapse-storage/core` is the *State Manager* — reactive
> storages (`MemoryStorage`/`LocalStorage`/`IndexedDB`) and selectors, usable on their own.
> On top sits the *Business Logic Layer* — `Dispatcher` / `Effects` / `createSynapse`.
> `rxjs` and `react` are optional peers: take only what you use.
## Key Features
- **Sync & Async Storage** — MemoryStorage, LocalStorage (synchronous), IndexedDB (async) with unified API
- **Selectors** — memoized computed values with dependency tracking
- **Immer-like Updates** — mutate state directly inside `update()` callbacks
- **API Client** — HTTP client with tag-based caching and invalidation
- **Persist Migrations** — `version` + `migrate(oldState, oldVersion)` for localStorage/IndexedDB
- **SSR Hydration** — `storage.hydrate(state)` to seed server-rendered state; `createSynapseCtx` + `dehydratedState` prop for seeded stores (SSR on by construction, no `ssr` flag); data-less "background" providers server-render on their own (synchronous C-form → auto `buildSyncShell`)
- **React Integration** — hooks on `useSyncExternalStore` (Concurrent Mode safe)
- **RxJS Effects** — dispatchers, effects, and watchers (Redux-Observable style)
- **Middleware** — extensible sync/async pipelines (batching, shallowCompare, logger, broadcast)
- **EventBus** — decoupled inter-module communication with wildcards
- **Cross-tab Sync** — BroadcastChannel middleware for multi-tab state
## Class-based modules
A module is four thin classes over the same engines. Action / selector names come from
**field names**, API lifecycles are **callable groups**, and assembly is a **lazy singleton
handle**.
> **v5 note.** The functional API (`createSynapse(config)`, `defineAction`,
> `createDispatcher`, `createApiActions`, `createSelectorsFn`) was removed in **v5.0.0**.
> Class-based modules are the only form. On v4.x both forms coexist — see the migration
> table below.
```typescript
import { Dispatcher, Effects, ofType, validateMap, fromRequest, apiResult } from 'synapse-storage/reactive'
import { Selectors, MemoryStorage } from 'synapse-storage/core'
import { createSynapse } from 'synapse-storage'
// — Dispatcher: action name = field name. apiActions returns a CALLABLE group —
class PostsDispatcher extends Dispatcher<PostsState> {
// d.loadPosts(params) = init intent; d.loadPosts.loading/.success/.failure/.reset = lifecycle
loadPosts = this.apiActions<PostsFindAllParams>((s) => s.api.postsRequest)
mounted = this.signal<FeedPayload>('Feed mounted') // pure signal
applyPosts = this.action((store, page: PostsPage) => // (storage, params) => result
store.update((s) => { s.list = page.data }))
}
// — Selectors: eager fields, cross-store deps via constructor —
class PostsSelectors extends Selectors<PostsState> {
constructor(storage: IStorage<PostsState>, private core: CoreSelectors) { super(storage) }
private readonly api = this.select((s) => s.api) // private = intermediate
list = this.select((s) => s.list)
isPostsLoading = this.combine([this.api], (a) => a.postsRequest.status === 'loading')
currentUserId = this.combine([this.core.profile], (p) => p?.id ?? null) // cross-store
}
// — Effects: services/external stores via constructor, captured in the closure —
class PostsEffects extends Effects<PostsState, PostsDispatcher> {
constructor(private api: PostsEndpoints) { super() }
load = this.effect((action$, state$, { dispatcher: d }) =>
action$.pipe(
ofType(d.loadPosts), // catches ONLY init
validateMap({
loadingAction: () => d.loadPosts.loading(),
errorAction: (e) => d.loadPosts.failure(String(e)),
apiCall: ([action]) => fromRequest(this.api.getPosts.request(action.payload)).pipe(
apiResult((page) => { d.applyPosts(page); d.loadPosts.success() }),
),
}),
))
override onDestroy() { /* close sockets etc. */ }
}
// — Assembly (C-form): synchronous core construction; all async lives in `effects` —
export const postsSynapse = createSynapse({
storage: () => new MemoryStorage<PostsState>({ name: 'posts', initialState }),
dispatcher: (s) => new PostsDispatcher(s),
selectors: (s) => new PostsSelectors(s, coreSynapse.selectors), // cross-store DI, synchronous
dependencies: [coreSynapse], // gate for effects START
effects: async () => new PostsEffects(await getPostsApi()), // async resolves here
})
const { storage, state$, dispatcher, actions, selectors } = await postsSynapse
```
### Rules to keep in mind
- **`ofType(d.loadPosts)` matches only `init`.** To react to a result, listen explicitly:
`ofType(d.loadPosts.success)`.
- **Services only in closures.** A constructor service (`this.api`) may be captured inside
the `this.effect(fn)` recipe, but not dereferenced in a field initializer — parameter
properties are assigned *after* derived-class field initializers run.
- **Reserved field names** (`storage`, `action$`, `actions`, `dispatch`, `watchers`, `use`,
`destroy`) cannot be used for actions; a field-alias (one action under two names) is
rejected at finalization with a clear error.
- **Cross-store eager selectors** require `useDefineForClassFields: false` (so field
initializers run after parameter-property assignment), or initialize those selectors in
the constructor body.
### React
```tsx
import { createSynapseCtx, useObservable, useSubscription } from 'synapse-storage/react'
// Pass the handle (not a call) — factory starts lazily on first Provider mount:
export const { contextSynapse: withPosts, useSynapseSelectors, useSynapseActions } =
createSynapseCtx(postsSynapse, { loadingComponent: <Spinner /> })
// Reactive reads straight in the component (write still goes through actions):
const debounced = useObservable(() => selectors.searchQuery.$.pipe(debounceTime(300), distinctUntilChanged()),
'',
[selectors],
)
useSubscription(() => selectors.lastId.$.pipe(skip(1), tap(scrollToEnd)), [selectors])
```
### Reactive reads from a storage (controlled re-renders)
Mutate the store with ordinary methods (`set`/`update`) and read it reactively in a component.
Pick the hook by how much control over re-renders you need:
```tsx
import { useStorageSubscribe, useStorageObservable, useStorageRef } from 'synapse-storage/react'
// 1. Always re-render on change (canonical, RxJS-free, Concurrent-safe).
// `equals` skips the re-render when the selected slice is unchanged.
const todos = useStorageSubscribe(storage, (s) => s.todos, { equals: (a, b) => a === b })
// 2. RxJS path — same, but you can pipe operators. Memoizes the observable for you,
// so no extra re-subscribes (don't inline `toObservable(storage)` in render).
const userId = useStorageObservable(storage, (s) => s.user.id)
// 3. You control the re-renders. The ref always holds the fresh value; nothing
// re-renders unless you ask.
const { ref, get, rerender } = useStorageRef(storage, (s) => s.count)
// - "no re-render at all": read get() inside an event handler
// - "re-render when I decide": call rerender()
// - "re-render conditionally": useStorageRef(storage, sel, { shouldRerender: (prev, next) => ... })
```
For non-React / effect usage, `toObservable(storage, selector?)` turns a storage into an
`Observable` of the whole state (or a slice with `distinctUntilChanged` when a selector is given).
### Migration from v4 (functional → class-based)
On v4.x the migration is mechanical and per-file — convert one module, leave the rest on
the old form (cross-dependencies stay compatible). In v5.0.0 the functional form is gone.
See the full `pokemon-class` example in `packages/examples` (next to the functional
`pokemon-advanced`).
| Old (functional) | New (class-based) |
|-------------------------------------------------------------|-----------------------------------------------------------|
| `defineAction<S>()` + `createDispatcher(...)` registry | fields on `class extends Dispatcher<S>` |
| `createApiActions` flattened into 5 keys by hand | one `this.apiActions(accessor)` callable group |
| `dispatcher.dispatch.loadPostsLoading()` | `d.loadPosts.loading()` |
| `createSelectorsFn: (s) => ({ ... })` | fields on `class extends Selectors<S>` |
| external selectors typed twice (value + manual type) | a constructor parameter (`private core: CoreSelectors`) |
| 6-slot `Effect<...>` generics + `services`/`externalStates` | `class extends Effects<S, D, Ext?>`, deps via constructor |
| `createFeatureSynapse` userland wrapper | built-in lazy handle from `createSynapse({ storage, … })` |
| `createSynapseCtx(getPostsSynapse())` (eager on import) | `createSynapseCtx(postsSynapse)` (lazy handle) |
## Documentation
Full documentation, API reference, and examples available on [GitHub](https://github.com/Vlad92msk/synapse).
## License
MIT