@qtpy/state-management-observable
Version:
1. [Основная идея и архитектура](#основная-идея-и-архитектура) 1.1. [Прозрачная реактивность](#прозрачная-реактивность) 1.2. [Поддержка типизированных строковых ключей](#поддержка-типизированных-строковых-ключей) 1.3. [Подписки с «гранул
1,496 lines (1,060 loc) • 69.5 kB
Markdown
# Пример и краткая документация по `ObservableStore`
1. [Основная идея и архитектура](#основная-идея-и-архитектура)
1.1. [Прозрачная реактивность](#прозрачная-реактивность)
1.2. [Поддержка типизированных строковых ключей](#поддержка-типизированных-строковых-ключей)
1.3. [Подписки с «гранулярностью» путей](#подписки-с-гранулярностью-путей)
1.4. [Кэш-ключи (cacheKeys)](#кэш-ключи-cachekeys)
1.5. [Middleware-подход](#middleware-подход)
1.6. [Batching-обёртка](#batching-обёртка)
1.7. [Асинхронные обновления](#асинхронные-обновления)
1.8. [История изменений (undo/redo)](#история-изменений-undoredo)
1.9. [Интеграция с любым фреймворком](#интеграция-с-любым-фреймворком)
2. [Пример создания основного store (с middleware)](#пример-создания-основного-store-с-middleware)
2.1 [Что делает `DepthPath` и зачем он нужен](#что-делает-DepthPath-и-зачем-он-нужен)
2.2 [Что такое `Accessor` и зачем он нужен](#что-такое-Accessor-и-зачем-он-нужен)
2.3 [Что такое `CacheKeys` и зачем они нужен](#что-такое-CacheKeys-и-зачем-они-нужен)
3. [API обертки ssrStore](#api-обертки-ssrstore)
3.1. [`snapshot()`](#snapshot)
3.2. [`getSerializedStore(type)`](#getserializedstoretype)
3.3. [`getSSRStoreId()`](#getssrstoreid)
3.4. [`hydrate()`](#hydrated)
3.5. [`hydrateWithDocument(delay?, callback?)`](#hydratewithdocumentdelay-callback)
3.6. [`getIsSSR`](#getisssr)
3.7. [`updateSSR`](#updatessr)
4. [API createObservableStore](#api-createobservablestore)
4.1. [`store.subscribe(callback, cacheKeys?)`](#storesubscribecallback-cachekeys)
4.2. [`store.subscribeToPath(pathOrAccessor, callback, options?)`](#storesubscribetopathpathoraccessor-callback-options)
4.3. [`store.invalidate(cacheKey)`](#storeinvalidatecachekey)
4.4. [`store.get(pathOrAccessor)`](#storegetpathoraccessor)
4.5. [`store.update(pathOrAccessor, valueOrFn, options)`](#storeupdatepathoraccessor-valueorfn-options)
4.6. [`store.resolveValue(pathOrAccessor, valueOrFn)`](#storeresolvevaluepathoraccessor-valueorfn)
4.7. [`store.resolvePath(pathOrAccessor)`](#storeresolvepathpathoraccessor)
4.8 [`store.getRawStore()`](#storegetrawstore)
4.9 [`store.setRawStore()`](#storegetrawstore)
4.10 [`store.invalidateAll()`](#storeinvalidateall)
5. [Асинхронные обновления](#асинхронные-обновления)
5.1. [`store.asyncUpdate(pathOrAccessor, asyncUpdater, options?)`](#storeasyncupdatepathoraccessor-asyncupdater-options)
5.2. [`store.cancelAsyncUpdates(pathOrAccessor?)`](#storecancelasyncupdatespathoraccessor)
5.3. [`store.debounced(callback, delay)`](#storedebouncedcallback-delay)
5.4. [`store.isAborted(pathOrAccessor)`](#storeisabortedpathoraccessor)
6. [Батчинг (`store.batch`)](#батчинг-storebatch)
7. [История (undo/redo)](#история-undoredo)
7.1. [`store.undo(pathOrAccessor, spliceIndices?)`](#storeundopathoraccessor-spliceindices)
7.2. [`store.redo(pathOrAccessor, spliceIndices?)`](#storeredopathoraccessor-spliceindices)
7.4. [`store.getUndo(pathOrAccessor, step)`](#storegetundopathoraccessor-step)
7.3. [`store.getRedo(pathOrAccessor, step)`](#storegetredopathoraccessor-step)
7.5. [`store.getHistory(pathOrAccessor)`](#storegetHistorypathoraccessor)
7.6. [`store.clearHistoryPath(pathOrAccessor, mode?, spliceIndices?)`](#storeclearhistorypathpathoraccessor-mode-spliceindices)
7.7. [`store.clearAllHistory()`](#storeclearAllHistory)
8. [Статистика и очистка](#статистика-и-очистка)
8.1. [`store.getMemoryStats()`](#storegetmemorystats)
8.2. [`store.clearStore()`](#storeclearstore)
9. [Промежуточная обработка (Middleware)](#промежуточная-обработка-middleware)
9.1. [Когда срабатывает middleware](#1-когда-срабатывает-middleware)
9.2. [Изменение `value` внутри middleware](#2-изменение-value-внутри-middleware)
9.3. [Блокировка изменения](#3-блокировка-изменения)
9.4. [Последовательность нескольких middleware](#4-последовательность-нескольких-middleware)
10. [Основные преимущества такого подхода](#основные-преимущества-такого-подхода)
11. [Вывод](#вывод)
---
Ниже приведён пример реализации универсального реактивного стора (ObservableStore), написанного на TypeScript без привязки к конкретному фреймворку. Такой стор можно легко «подключить» в любом фронтенд-фреймворке (React, Vue, Svelte, Solid и т. д.) путём написания нескольких обёрток (адаптеров) поверх базового API.
- Обертка под react: [@qtpy/state-management-react](https://www.npmjs.com/package/@qtpy/state-management-react)
---
## Основная идея и архитектура
1. #### Прозрачная реактивность
Реактивность реализована через систему подписок и JavaScript Proxy:
- **Система подписок**: Хранит подписчиков (`subscribers`, `pathSubscribers`) и уведомляет их об изменениях через `notifyInvalidate`. Работает независимо от прокси, используя `getRaw` и `setRaw`.
- **Система прокси**: Перехватывает операции чтения (`get`), записи (`set`) и удаления, собирает зависимости в `trackedPaths` и инициирует обновления через `store.update`, уведомляя подписчиков.
- **Взаимодействие**: Подписки независимы, прокси зависит от них для уведомлений. Middleware интегрируется в цепочку обновлений.
2. #### Поддержка типизированных строковых ключей
- В ObservableStore реализована строго типизированная система строковых путей, которая позволяет:
- безопасно указывать строки путей вроде "user.settings.theme" без риска ошибок типов;
- получать автодополнение путей при использовании TypeScript;
- обеспечивать валидацию пути и значения: если путь некорректен — TypeScript подскажет об ошибке на этапе компиляции;
- поддерживать доступ к кортежам и массивам по индексам, например: "items.0" или "list.2.name".
- пример: [ссылка на картинку](https://raw.githubusercontent.com/TheOnlyFastCoder2/QtPy-library/refs/heads/main/state-management/main/packages/observable/assets/picture_autocomplete.png)
<img src="https://raw.githubusercontent.com/TheOnlyFastCoder2/QtPy-library/refs/heads/main/state-management/main/packages/observable/assets/picture_autocomplete.png" width="1200"/>
3. #### Подписки с «гранулярностью» путей
- Можно подписаться на любое конкретное поле вложенного объекта, используя либо строку-путь (`"user.settings.theme"`), либо Accessor: `($, t) => $.user.settings.theme`.
- При изменении именно этого поля подписчики получат уведомление. Изменения в других полях не затронут эту подписку.
4. #### Кэш-ключи (cacheKeys)
- Позволяют группировать «логические зависимости» (например, вычисляемые свойства).
- При вызове `store.invalidate(key)` все подписчики, передавшие этот `cacheKey` при подписке, будут уведомлены, даже если напрямую поле остался тем же.
5. #### Middleware-подход
- При каждом `update` (или прямой записи через Proxy) можно выполнить цепочку middleware, чтобы логировать, валидировать или блокировать изменения.
- Каждый middleware видит путь (`string`) и новое значение, может модифицировать или остановить дальнейшее распространение.
6. #### Batching-обёртка
- Позволяет «склеивать» несколько изменений в одно уведомление. Это важно, чтобы не вызывать повторный ререндер UI при последовательных взаимосвязанных изменениях.
7. #### Асинхронные обновления
- Метод `asyncUpdate(pathOrAccessor, asyncFn, options?)` позволяет выполнять асинхронные операции (fetch, таймеры, запросы) и автоматически отменять предыдущие, если они всё ещё в процессе (опция `abortPrevious`).
8. #### История изменений (undo/redo)
- Для указанных в `customLimitsHistory` свойств сохраняется история изменений с заданным лимитом.
- Методы `undo(pathOrAccessor)` и `redo(pathOrAccessor)` позволяют откатиться на предыдущие/следующие значения.
9. #### Интеграция с любым фреймворком
- Базовое API стора не зависит от React/Vue и т. д.
- Для React достаточно написать хук `useObservableStore`, который внутри использует `store.subscribeToPath` с Accessor’ом, и диспатчит обновление компонента. Аналогично для Vue/Svelte/Solid достаточно написать адаптер, который на изменение Proxy вызывает реактивное обновление.
---
## Пример создания основного `store` (с middleware)
```ts
// ObservableStore.ts
import { createObservableStore, Middleware } from '@qtpy/state-management-observable';
// 1) Определяем начальный interface :
interface StoreState {
user: {
name: string;
age: number;
settings: {
theme: string;
locale: string;
};
};
items: number[];
counter: number;
}
// 2) Определяем начальный стейт:
const initialState = {
user: {
name: 'Alice',
age: 30,
settings: {
theme: 'light',
locale: 'ru',
},
},
items: [1, 2, 3],
counter: 0,
};
// 3) Глубина тип поиска строковых путей
type DepthPath = 3;
// 3) Пример middleware: простой логгер перед и после update
const loggerMiddleware: Middleware<StoreState, DepthPath> = (store, next) => {
return (path, value) => {
console.log(`[Logger] До обновления: путь="${path}", старое значение=`, store.get(path));
next(path, value);
console.log(`[Logger] После обновления: путь="${path}", новое значение=`, store.get(path));
};
};
// 3) Создаём стор с middleware и ограничением истории:
export const store = createObservableStore<StoreState, DepthPath>(
initialState,
[loggerMiddleware], // цепочка middleware
{
customLimitsHistory: [
// Для свойства counter сохраняем до 3 предыдущих состояний
['counter', 3],
['user.settings.locale', 2],
['items.2', 3],
[($) => $.items[3], 3],
[($) => $.items, 3],
],
}
);
```
## API `createObservableStore`
### `store.subscribe(callback, cacheKeys?)`
Это метод глобальной подписки на любые изменения в состоянии, вне зависимости от затронутого поля. Он позволяет отслеживать все обновления стора. При указании массива `cacheKeys`, подписка будет срабатывать только при изменениях, затрагивающих указанные зависимости. Это удобно для логгирования, аналитики, синхронизации и системного поведения, которое должно реагировать на любые модификации состояния.
- **Параметры:**
1. `callback: (state) => void` — вызывается после каждого «батча» изменений.
2. `cacheKeys?: string[]` — массив строковых ключей (cacheKey). Если указан, уведомление придёт только тогда, когда:
- **Пример:**
```ts
// Подписываемся на все апдейты:
const unsubAll = store.subscribe((fullState) => {
console.log('Весь стейт изменился:', fullState);
});
// Подписка, опирающаяся на cacheKey "user.settings.theme":
const unsubFiltered = store.subscribe(
(fullState) => console.log('Тема пользователя:', fullState.user.settings.theme),
['user.settings.theme']
);
// Отписка:
unsubAll();
unsubFiltered();
```
---
### `store.subscribeToPath(pathOrAccessor, callback, options?)`
subscribeToPath позволяет подписаться на изменения конкретного поля в состоянии, указав путь как строку или Accessor-функцию. Срабатывает только при изменении указанного участка данных, обеспечивая точный контроль над реактивностью.
- **Параметры:**
1. `pathOrAccessor`
2. `callback: (newValue) => void` — вызывается при изменении указанного пути.
3. `options?: { immediate?: boolean; cacheKeys?: string[] }` —
- `immediate: true` — сразу вызываем callback с текущим значением, даже до первого изменения.
- `cacheKeys: string[]` — список cacheKeys; колбэк будет вызываться при этом событии, даже если путь не менялся напрямую (см. `store.invalidate`).
- **Примеры:**
```ts
// 1) Подписка на изменение user.name по строковому пути:
const unsubName = store.subscribeToPath('user.name', (newName) => console.log('Имя пользователя:', newName), {
immediate: true,
});
// 2) Подписка на первый элемент массива items:
// Здесь index может меняться динамически внутри Accessor через t(index)
let idx = 0;
const unsubFirstItem = store.subscribeToPath(
($, t) => $.items[t(idx)], // Accessor<any>
(val) => console.log('Первый элемент массива:', val),
{ cacheKeys: ['counter'] }
);
// 3) Отписка:
unsubName();
unsubFirstItem();
```
---
### `store.invalidate(cacheKey)`
Инвалидирует указанный строковый ключ (`cacheKey`). Всем глобальным подписчикам, которые при подписке передали этот ключ в `cacheKeys`, придёт уведомление (даже если напрямую значение по их пути не менялось).
- **Пример:**
```ts
// Если где-то в логике нужно форсировать оповещение по подписчикам, полагающимся на cacheKey:
store.invalidate('user.settings.theme');
store.invalidate(($) => $.user.settings.scheme);
```
---
### `store.get(pathOrAccessor)`
Метод store.get(pathOrAccessor) используется для безопасного получения значения из стора — по строковому пути ("user.age") или Accessor-функции. В отличие от прямого доступа через $.myObject, он гарантирует корректное извлечение даже при работе с реактивными объектами, проксями и вложенными структурами. Это особенно важно,`чтобы избежать ошибок вроде Uncaught TypeError: Illegal invocation`, которые могут возникнуть при вызове нативных методов DOM или работы с объектами вне контекста. Метод также улучшает читаемость, типизацию и делает код предсказуемым при масштабировании.
- **Пример:**
```ts
const age = store.get('user.age'); // 30
console.log('Возраст:', age);
// Пример с Accessor: читаем элемент массива по динамическому индексу
let idx = 1;
const firstItem = store.get(($, t) => $.items[t(idx)]);
console.log('Второй элемент массива:', firstItem); // 2
```
---
### `store.update(pathOrAccessor, valueOrFn, options)`
Метод для синхронного обновления значения в хранилище. Он принимает:
- `pathOrAccessor`: строку типа `"user.age"` или Accessor-функцию
- `valueOrFn`: новое значение или функцию `(cur) => next`
- `options`: объект с параметрами, включая `keepQuiet`
---
#### Механизм Snapshot перед/после обновления
При каждом вызове `update()`:
- Перед обновлением создаётся снимок (snapshot) текущей структуры объекта или массива без инстанцирования нового экземпляра.
- Старое значение сохраняется в историю (до `maxHistoryLength`).
- Запускаются middleware (в порядке регистрации).
- Применяется фактическое обновление.
- После обновления формируется новый снимок и сравниваются хеши старого и нового состояний, чтобы точно определить, что изменилось.
- Подписчики уведомляются только при реальных отличиях в данных.
Это позволяет:
- Минимизировать лишние обновления UI
- Обеспечить корректную работу undo/redo
- Эффективно отследить изменения для дебага
---
#### Поддержка мутаций массивов через Proxy
Даже косвенные изменения через методы `push`, `splice`, `sort` и другие — отслеживаются:
- Все обращения проходят через `store.$`, перехватываются `Proxy`
- Изменения через методы → автоматически вызывают `store.update(...)`
- Поддерживается история, middleware, нотификации
Примеры:
```ts
store.$.items.push(100); // вызовет уведомление
store.$.items.splice(1, 2); // → нотификация по "items"
store.$.user.name = 'Charlie'; // → нотификация по "user.name"
```
---
#### Использование `update()` — варианты
1. **По строковому пути**:
```ts
store.update('user.age', 35);
```
2. **Через функцию**:
```ts
store.update('user.age', (cur) => cur + 1);
```
3. **С `keepQuiet`** — чтобы не уведомлять подписчиков:
```ts
store.update('user.age', 36, { keepQuiet: true });
store.update.quiet('user.age', 36);
```
4. **Через Accessor с динамикой**:
```ts
let idx = 2;
store.update(
($, t) => $.items[t(idx)],
(cur) => cur * 10
);
```
5. **Массив и сравнение snapshot**:
```ts
store.update('items', (prev) => {
return prev.reverse(); // вызовет уведомление
});
store.update('items', (prev) => {
return prev; // не вызовет — snapshot не изменился
});
```
---
### `store.resolvePath(pathOrAccessor)`
`resolvePath` преобразует путь к данным (строку вида `'a.b'` или функцию `$ => $.a.b`) в строковый формат для внутреннего использования в хранилище. Гарантирует валидность пути и соответствие глубине вложенности.
**Может пригодиться в Middleware**
- **Пример:**
```ts
store.resolvePath('user.name'); // → 'user.name'
store.resolvePath(($) => $.profile.age); // → 'profile.age'
```
---
### `store.resolveValue(pathOrAccessor, valueOrFn)`
Этот метод для безопасного "предпросмотра" обновления состояния. Он рассчитывает, каким будет итоговое значение после применения функции или прямого значения, не меняя данные в сторе. Это удобно, когда нужно сделать условную проверку, провести валидацию, сравнить с текущим значением или подготовить сложную логику обновления, не рискуя триггерить подписки или ререндеры.
**Может пригодиться в Middleware**
- **Пример:**
```ts
const nextCounter = store.resolveValue('counter', (cur) => cur + 5);
console.log('Будет следующий counter:', nextCounter);
// Но store.get("counter") остаётся прежним.
```
---
### `store.getRawStore()`
`getRaw` возвращает текущее «сырое» состояние без прокси и без обёрток реактивности. Это полезно, если нужно напрямую получить **весь state как объект** и работать с ним без триггера подписчиков.
- **Пример:**
```ts
const state = store.getRaw();
console.log(state);
// { count: 1, user: { name: "Ann" } }
```
---
### `store.setRawStore(newState, options?)`
полностью заменяет весь state на новый объект. При этом:
- все асинхронные операции отменяются,
- история undo/redo очищается,
- отложенные (debounced) операции сбрасываются,
- подписчики остаются и будут уведомлены о новом состоянии (если не указать `keepQuiet`).
- **Пример:**
```ts
store.setRaw({ count: 42, user: { name: 'Bob' } });
// Подписчики вызовутся с новым состоянием
store.setRaw({ count: 100 }, { keepQuiet: true });
// Подписчики НЕ вызовутся
```
---
### `store.invalidateAll()`
`invalidateAll` форсированно запускает **все подписки** (и глобальные, и path-подписки), даже если состояние не изменилось.
Это удобно, когда нужно «перепросчитать» все зависимости, например после полной перезагрузки стейта.
- **Пример:**
```ts
store.subscribe((s) => {
console.log('sub:', s);
});
store.invalidateAll();
// "sub:" { count: 42, user: { name: "Bob" } }
```
## Асинхронные обновления
### `store.asyncUpdate(pathOrAccessor, asyncUpdater, options?)`
Асинхронный метод обновления - полезно если нужно отменить предыдущий запрос. При включённой опции `abortPrevious: true`, все незавершённые обновления по тому же пути автоматически прерываются, исключая гонки данных и обеспечивая корректное состояние. Это удобно при вводе в поле, дебаунсе, сетевых запросах или при переключении контекста.
- Если указан `options.abortPrevious: true`, предыдущий незавершённый запрос по тому же пути будет отменён при помощи `AbortController`.
- **Пример:**
```ts
// Загрузим список с сервера и запишем в $.items:
await store.asyncUpdate(
'items',
async (currentItems, signal) => {
const response = await fetch('/api/items', { signal });
const data = await response.json();
return data.list;
},
{ abortPrevious: true }
);
```
---
### `store.asyncUpdate.quiet(pathOrAccessor, asyncUpdater, options?)`
Вариант `asyncUpdate`, который **не вызывает перерисовку компонентов и не активирует подписки**. Это удобно для фоновых обновлений или временных значений, которые не должны вызывать реакцию в UI.
- **Пример:**
```ts
// Временно устанавливаем $.status = 'loading', без триггера подписок
await store.asyncUpdate.quiet(
'status',
async (_, signal) => {
await new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, 1000);
signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(new DOMException('Aborted', 'AbortError'));
});
});
return 'idle';
},
{ abortPrevious: true }
);
```
---
### `store.cancelAsyncUpdates(pathOrAccessor?)`
Метод для отмены активных асинхронных обновлений, запущенных через asyncUpdate. Если передать конкретный путь, отменяются запросы только по нему; если вызвать без аргументов — завершатся все «висящие» обновления во всём сторе. Это помогает избежать конфликтов данных, гонок, или лишней нагрузки, особенно при переключении экрана, logout'е, или повторном вводе данных.
- **Пример:**
```ts
// Отменить все асинхронные обновления:
store.cancelAsyncUpdates();
// Отменить только для пути "items":
store.cancelAsyncUpdates('items');
```
---
### `store.debounced(callback, delay)`
Метод debounced создаёт функцию с отложенным вызовом, которая будет выполнена только после паузы между последовательными вызовами. Это особенно полезно для асинхронных операций, таких как HTTP-запросы, где важно избегать лишней нагрузки и отменять устаревшие запросы.
**Пример использования:**
```ts
type DepthPath = 1;
const debouncedFetchItems = store.debounced(
(path: PathOrAccessor<StoreState, DepthPath>, userId: number) => {
store.asyncUpdate(
path,
async (currentItems, signal) => {
const response = await fetch(`/api/items?user=${userId}`, { signal });
const data = await response.json();
return data.list;
},
{ abortPrevious: true }
);
},
1000 // задержка 1 секунда
);
// Использование
debouncedFetchItems('items', 123);
debouncedFetchItems('items', 456);
debouncedFetchItems.cancel();
debouncedFetchItems(($) => $.items, 452363);
```
### `store.isAborted(pathOrAccessor)`
Проверяет, был ли прерван (`abort`) асинхронный апдейт по указанному пути или Accessor. Метод использует внутренний `AbortController`, связанный с этим путём.
- Возвращает `true`, если для данного пути существует контроллер и его `signal.aborted === true`.
- Возвращает `false`, если контроллера нет или обновление ещё не отменено.
- **Пример:**
```ts
// Запускаем асинхронное обновление
const promise = store.asyncUpdate('counter', async (current, signal) => {
await new Promise((r) => setTimeout(r, 1000));
return current + 1;
});
// Прерываем по ключу 'counter'
store.abort('counter');
console.log(store.isAborted('counter')); // true
```
---
## Батчинг (`store.batch`)
### `store.batch(callback)`
Позволяет объединить несколько изменений состояния в один реактивный блок, при этом уведомления подписчикам отправляются только после завершения всех операций. Это уменьшает количество лишних ререндеров, увеличивает производительность и делает логику обновлений более предсказуемой. Особенно полезно при комплексных изменениях, когда нужно обновить сразу несколько полей без промежуточных реакций.
- **Примеры:**
```ts
// 1) Через метод update:
await store.batch(() => {
store.update('user.name', 'Charlie');
store.update('user.age', (cur) => cur + 2);
store.update('items.0', 100);
store.update('items.0', 200); // два изменения одного пути
});
// Подписчики получат одно уведомление:
// - user.name = "Charlie"
// - user.age = previous + 2
// - items.0 = 200 (только последнее)
// 2) С прямыми присваиваниями:
await store.batch(() => {
store.$.user.name = 'Charlie';
store.$.user.age = 23;
store.$.items[0] = 100;
store.$.items[0] = 200; // два присваивания
});
// Подписчики увидят изменения:
// "user.name", "user.age", "items.0" — со значением 200
```
---
## История (undo/redo)
История изменений (undo/redo) не отслеживается автоматически для всех полей состояния. Чтобы активировать историю для конкретных свойств, необходимо явно указать их в параметре customLimitsHistory при создании хранилища.
```ts
const store = createObservableStore<AppState, DepthPath>(initialState, [], {
customLimitsHistory: [
['user.age', 5], // [path, лимит]
[($) => $.items[0], 3], // [accessor, лимит]
],
});
```
- **Важно:** история не реагирует на косвенные изменения массивов через методы (например, `store.$.items.push(23)`), поэтому такие правки **не** попадают в стек `undo/redo`.
### `store.undo(pathOrAccessor, spliceIndices?)`
Что делает: Откатывает (undo) последнее изменение по указанному пути или Accessor. Параметр `spliceIndices` (опционально) — кортеж `[start, deleteCount]`, задающий индексы для удаления элементов из undo-стека, чтобы ограничить историю изменений. Если предыдущая запись существует, восстанавливает её и возвращает `true`. Иначе возвращает `false`.
- **Пример:**
```ts
store.update('counter', 10);
store.update('counter', 20);
console.log(store.get('counter')); // 20
store.undo('counter'); // Откат к 10
console.log(store.get('counter')); // 10
store.undo('counter', [0, 1]); // Удаляет первую запись из undo-стека
```
---
### `store.redo(pathOrAccessor, spliceIndices?)`
Повторяет (redo) последнее отменённое изменение по указанному пути или Accessor. Параметр `spliceIndices` (опционально) — кортеж `[start, deleteCount]`, задающий индексы для удаления элементов из redo-стека. Если есть отменённое значение, применяет его и возвращает `true`. Иначе возвращает `false`.
- **Пример:**
```ts
store.update('counter', 10);
store.update('counter', 20);
store.undo('counter'); // Возвращает к 10
store.redo('counter'); // Возвращает к 20
console.log(store.get('counter')); // 20
store.redo('counter', [0, 1]); // Удаляет первую запись из redo-стека
```
---
Вот документация в таком же формате для методов `store.getUndo`, `store.getRedo` и `store.getHistory`:
---
### `store.getUndo(pathOrAccessor, step)`
- **Что делает:** возвращает значение из undo-истории на указанное количество шагов назад по пути `pathOrAccessor`.
- `step = 0` — текущее значение.
- `step = 1` — предыдущее значение.
- Если шаг выходит за границы undo-истории, возвращает `undefined`.
- **Пример:**
```ts
store.$.counter = 10;
store.$.counter = 20;
store.$.counter = 30;
store.getUndo('counter', 0); // 30
store.getUndo('counter', 1); // 20
store.getUndo('counter', 2); // 10
store.getUndo('counter', 3); // undefined
```
---
### `store.getRedo(pathOrAccessor, step)`
- **Что делает:** возвращает значение из redo-истории на указанное количество шагов вперёд по пути `pathOrAccessor`.
- `step = 0` — ближайшее значение для повтора.
- `step = 1` — следующее за ним и т.д.
- Если redo-история пуста или шаг выходит за границы, возвращает `undefined`.
- **Пример:**
```ts
store.update('counter', 10);
store.update('counter', 20);
store.undo('counter'); // возвращает к 10
store.undo('counter'); // возвращает к undefined
store.getRedo('counter', 0); // 10
store.getRedo('counter', 1); // 20
store.getRedo('counter', 2); // undefined
```
---
### `store.getHistory(pathOrAccessor)`
- **Что делает:** возвращает полную историю изменений по указанному пути в виде объекта `{ undo, redo }`.
- `undo` — массив значений, от самого первого до текущего.
- `redo` — массив отменённых значений, доступных для повторного применения.
- **Пример:**
```ts
store.update('counter', 10);
store.update('counter', 20);
store.undo('counter');
const history = store.getHistory('counter');
console.log(history.undo); // [10]
console.log(history.redo); // [20]
```
---
### `store.clearHistoryPath(pathOrAccessor, mode?, spliceIndices?)`
Очищает историю изменений (undo/redo) для указанного пути или Accessor в состоянии. Параметр mode (опционально) определяет, какую часть истории очищать: только `'undo'`, только `'redo'` или обе (по умолчанию) если указать `'all'`. Параметр `spliceIndices` (опционально) — кортеж `[start, deleteCount]`, задающий индексы для удаления элементов из указанного стека, позволяя выборочно очищать часть истории. Метод не затрагивает текущее значение в сторе.
- **Пример:**
```ts
store.update('user.age', 25);
store.update('user.age', 30);
store.clearHistoryPath('user.age'); // Очищает всю историю для user.age
console.log(store.get('user.age')); // 30 (значение не изменилось)
store.clearHistoryPath(($) => $.user.age, 'undo', [0, 1]); // Очищает первую запись в undo-стеке
store.clearHistoryPath(($) => $.user.age, 'all', [0, 1]); // очищает первую запись у undo/redo
```
### `store.clearAllHistory()`
Этот метод для полного сброса всей истории изменений во всём сторе. Удаляет все сохранённые состояния undo/redo для всех полей одновременно.
- **Пример:**
```ts
store.clearAllHistory();
```
## Статистика и очистка
### `store.getMemoryStats()`
- **Что делает:** возвращает объект с текущими статистическими данными:
- `subscribersCount` — число глобальных подписчиков.
- `pathSubscribersCount` — число подписок по конкретным путям/Accessor’ам.
- `historyEntries` — список всех путей и длина их истории.
- `activePathsCount` — число активных путей (за которыми кто-то следит).
- `abortersCount` — Число активных асинхронных обновлений.
- **Пример:**
```ts
const stats = store.getMemoryStats();
console.log('Глобальных подписчиков:', stats.subscribersCount);
console.log('Подписок по путям:', stats.pathSubscribersCount);
console.log('Асинхронные обновления:', stats.abortersCount);
console.log('История:', stats.historyEntries);
```
---
### `store.clearStore()`
- **Что делает:** полностью очищает хранилище:
- Удаляет все подписки (глобальные и по путям).
- Отменяет все «висящие» асинхронные обновления.
- Очищает историю.
- **Пример:**
```ts
// Когда стор больше не нужен:
store.clearStore();
```
---
## Промежуточная обработка (Middleware)
`Middleware` — это функции, которые «оборачивают» вызовы `store.update(...)` и дают возможность перехватывать (модифицировать, логировать, блокировать) запросы на изменение состояния.
### 1. Когда срабатывает middleware
- Middleware вызываются **только** при:
1. вызове `store.update(...)`, или
2. прямой записи через Proxy (`store.$.some.key = newValue`).
- Если обновление обойти Proxy (например, напрямую поменять внутренний «сырой» объект вне Proxy), middleware не запустятся.
```ts
// Гарантированная активация middleware:
store.update('user.name', 'Dmitry');
store.$.user.name = 'Dmitry'; // Proxy перехватывает и идёт через middleware
// НЕ активирует middleware (не рекомендуется):
// (внутренний «сырый» объект здесь не трогает Proxy)
(store as any).rawState.user.name = 'Eve';
```
---
### 2. Изменение `value` внутри middleware
Внутри middleware есть доступ к исходному `path` и `value`. Можно изменить `value` перед тем, как передать его дальше по цепочке, вызвав `next(path, newValue)`.
```ts
const clampAgeMiddleware: Middleware<typeof initialState> = (store, next) => {
return (path, value) => {
const resVal = store.resolveValue(path, value);
if (path === 'user.age') {
// Ограничиваем возраст от 0 до 99:
const clamped = Math.max(0, Math.min(99, resVal));
next(path, clamped);
} else {
next(path, value);
}
};
};
// Теперь при store.update("user.age", 150) реально попадёт 99.
```
---
### 3. Блокировка изменения
Если внутри middleware **не вызвать** `next(path, value)`, весь дальнейший вызов метода `update` «глохнет» — изменения не применяются, а последующие middleware не вызываются.
```ts
const blockAgeMiddleware: Middleware<typeof initialState> = (store, next) => {
return (path, value) => {
if (path === 'user.age') {
console.warn('[Middleware] Изменение user.age заблокировано');
// Не вызываем next → изменение не произойдёт
return;
}
next(path, value);
};
};
// Пробуем:
store.update('user.age', 40);
// Лог: [Middleware] Изменение user.age заблокировано
// Возраст остаётся прежним
store.update('user.name', 'Bob');
// Проходит нормально, потому что для "user.name" вызывается next.
```
---
### 4. Последовательность нескольких middleware
При создании стора можно передать массив middleware, например: `[mw1, mw2, mw3]`. Порядок вызова (после реверса) таков:
1. mw1 → вызывает mw2
2. mw2 → вызывает mw3
3. mw3 → вызывает «ядро» update
Если на каком-то этапе `next` не вызывается, дальнейшие middleware и само ядро не получат управление, и стор не обновится.
```ts
const mw1: Middleware<typeof initialState> = (store, next) => {
return (path, value) => {
const resVal = store.resolveValue(path, value);
const resPath = store.resolvePath(path);
console.log('[MW1] До', path, resPath, resVal);
next(path, value);
console.log('[MW1] После', path, store.get(path), resPath);
};
};
const mw2: Middleware<typeof initialState> = (store, next) => {
return (path, value) => {
const resPath = store.resolvePath(path);
console.log('[MW2] Проверяем', path);
if (resPath === 'items.0') {
console.log('[MW2] Блокируем изменение items.0');
return; // mw3 и ядро не выполнятся
}
next(path, value);
};
};
const mw3: Middleware<typeof initialState> = (store, next) => {
return (path, value) => {
console.log('[MW3] Логика MW3');
next(path, value);
};
};
const store = createObservableStore(initialState, [mw1, mw2, mw3]);
// Пример:
store.update('items.0', 999);
// Лог:
// [MW1] До items.0 999
// [MW2] Проверяем items.0
// [MW2] Блокируем изменение items.0
// → mw1 не продолжит после next, mw3 не вызовется, update не применится.
store.update('user.name', 'Dmitry');
// Лог:
// [MW1] До user.name Dmitry
// [MW2] Проверяем user.name
// [MW3] Логика MW3
// [MW1] После user.name Dmitry
// → Значение применено.
```
---
## API обертки ssrStore
`ssrStore` — функция, расширяющая `ObservableStore` для поддержки серверного рендеринга (SSR).
Она добавляет методы для сериализации состояния на сервере, гидратации на клиенте и безопасного управления асинхронными обновлениями.
### Сигнатура
```typescript
function ssrStore<T extends object, D extends number = 0, S extends ObservableStore<T, D> = ObservableStore<T, D>>(
store: S,
ssrStoreId = 'ssrStoreId_default'
): S & {
snapshot: () => Promise<T>;
getSerializedStore: (type: 'window' | 'scriptTag' | 'serializedData') => Promise<string>;
getSSRStoreId: () => string;
hydrate: () => void;
hydrateWithDocument: (delay?: number, callback?: () => void) => void;
getIsSSR: () => boolean;
updateSSR: ObservableStore<T, D>['asyncUpdate'];
};
```
### Параметры
- `store`: Экземпляр `ObservableStore`.
- `ssrStoreId`: Уникальный идентификатор стора, чтобы выполнить гидратацию.
---
### Инициализация
При передачи свойства `ssrStoreId`, stm оборачивается в ssrStore:
```tsx
export const stGlobal = createObservableStore<State>({
counter: 0,
items: [],
}, [], {ssrStoreId: 'myStoreId'});
stGlobal.updateSSR('counter', async (c) => c + 1);
```
## Методы
### `snapshot()`
Возвращает текущее состояние стора после завершения всех асинхронных обновлений. Ждёт всю очередь асинхронных операций.
- **Пример:**
```ts
await stGlobal.updateSSR('counter', async (c) => c + 1);
await stGlobal.updateSSR('counter', async (c) => c + 5);
const snap = await stGlobal.snapshot();
console.log(snap.counter); // 6
```
---
### `getSerializedStore(type)`
Сериализует состояние в JSON.
- `type: 'window'` → Возвращает строку `window[ssrStoreId] = ${serializedData}`.
- `type: 'scriptTag'` → Возвращает `<script>` тег с JSON-данными.
- `type: 'serializedData'` → Возвращает только сериализованные данные.
- **Пример:**
```ts
const json = await stGlobal.getSerializedStore('serializedData');
// {"counter":0,"items":[]}
const window = await stGlobal.getSerializedStore('window');
// window['myStoreID'] = {"counter":0,"items":[]};
const script = await stGlobal.getSerializedStore('scriptTag');
// <script id="myStoreID" type="application/json">
// window['myStoreID'] = {"counter":0,"items":[]};
// </script>
```
#### пример на сервере
```ts
res.send(`
<html>
<body>
<div id="root">${html}</div>
${await stGlobal.getSerializedStore('scriptTag')}
</body>
</html>
`);
```
#### пример на ssr компонента
```ts
<script
id={stGlobal.getSSRStoreId()}
dangerouslySetInnerHTML={{
__html: await stGlobal.getSerializedStore('window'),
}}
/>
```
---
### `getSSRStoreId()`
Возвращает идентификатор стора (`ssrStoreId`).
- **Пример:**
```ts
console.log(stGlobal.getSSRStoreId()); // "myStoreID"
```
---
### `hydrate()`
Выполняет гидратацию на клиенте: восстанавливает состояние из `window[ssrStoreId]` и удаляет временные данные из DOM и `window`.
- **Пример:**
```ts
// после того как сервер вставил данные в window.myStoreID
stGlobal.hydrate();
console.log(stGlobal.get('counter')); // значение из server-side snapshot
```
---
### `hydrateWithDocument(delay?, callback?)`
Выполняет гидратацию на клиенте после события `window.onload`.
- Можно указать задержку (мс).
- Можно передать `callback`, который выполнится после гидратации.
- **Пример:**
```ts
// Без задержки
stGlobal.hydrateWithDocument();
// С задержкой
stGlobal.hydrateWithDocument(1000);
// С колбэком (например, для React-рендера)
stGlobal.hydrateWithDocument(0, () => {
createRoot(document.getElementById('root')!).render(<App />);
});
```
---
### `getIsSSR()`
Возвращает `true`, если код выполняется на сервере, иначе `false`.
- **Пример:**
```ts
if (stGlobal.getIsSSR()) {
console.log('Работаем на сервере');
} else {
console.log('Работаем в браузере');
}
```
---
### `updateSSR()`
Асинхронный метод для обновления состояния с сохранением порядка выполнения. Все вызовы ставятся в очередь и выполняются **строго последовательно**. Сигнатура полностью повторяет `asyncUpdate` из `ObservableStore`, включая метод `updateSSR.quiet`.
- `updateSSR(...)` обновляет состояние и уведомляет подписчиков.
- `updateSSR.quiet(...)` обновляет состояние **без уведомления подписчиков**.
- **Пример:**
```ts
// Очередь обновлений — выполнятся по порядку
await stGlobal.updateSSR('counter', async (c) => c + 1);
await stGlobal.updateSSR('counter', async (c) => c + 10);
// "quiet" версия
await stGlobal.updateSSR.quiet('items', async (arr) => [...arr, 42]);
console.log(stGlobal.get('counter')); // 11
console.log(stGlobal.get('items')); // [42]
```
#### Пример для react:
```typescript
//SSRMain.tsx
export default async function SSRMain() {
return (
<>
<Test1 />
<Test2 />
<Test3 />
</>
);
}
async function Test1() {
await stGlobal.updateSSR(($) => $.counter, async () => 10); // 10
return <></>;
}
async function Test2() {
await stGlobal.updateSSR(($) => $.counter, async (prevVal) => prevVal * 20); // 200
return <></>;
}
async function Test3() {
return (
<head>
<script
id={stGlobal.getSSRStoreId()}
dangerouslySetInnerHTML={{
__html: await stGlobal.getSerializedStore('window'),
}}
/>
</head>
);
}
```
---
## Особенности
- Отслеживает асинхронные обновления через `updateSSR`, выполняя их строго последовательно.
- Поддерживает безопасную передачу состояния между сервером и клиентом.
- Удаляет временные данные после гидратации.
- `hydrateWithDocument` обеспечивает гидратацию после полной загрузки DOM.
---
## Что делает `DepthPath` и зачем он нужен
`DepthPath` управляет тем, насколько глубоко TypeScript будет "раскрывать" вложенные свойства объекта, чтобы сгенерировать возможные строковые пути вида `"user.settings.locale"`, `"items.0"` и т. д.
```
По умолчанию стоит `DepthPath=0`.
```
**Пример:**
```tsx
type DepthPath = 3;
const store = createObservableStore<StoreState, DepthPath>({});
```
---
#### ✅ `DepthPath = 0`
- **Типы путей не вычисляются вообще.**
- Все проверки типов путей (`SafePaths`, `PathExtract`, `PathOrAccessor`) становятся заглушками.
- Значения принимаются по произвольным строкам, но:
- ❌ **автокомплита нет**,
- ❌ **типовая проверка путей и значений отключена**,
- ✅ Это может быть удобно для моков, тестов, или свободной работы без ограничений.
#### ⚠️ `DepthPath = 14` и выше
- ⚠️ **Потенциально медленно**: генерация union-типов путей становится экспоненциальной.
- ✅ Позволяет обращаться к глубоко вложенным путям, если они есть.
- ❌ Но может:
- привести к **замедлению или зависанию TypeScript/IDE (VSCode)**,
- вызвать **ошибки "Type instantiation is excessively deep..."** при сложных типах.
**Рекомендуется:**
> Не использовать `DepthPath > 10`, **если только это не оправдано реально вложенными структурами.**
### Итог
| `DepthPath` | Поведение |
| ----------- | ----------------------------------------------------------------------- |
| `0` | Заглушка: любые строки, нет проверки и автокомплита |
| `1–7` | Оптимально: безопасные пути, разумная глубина |
| `8–13` | Допустимо, но уже может тормозить IDE |
| `14+` | Риск перегрузки компилятора, не рекомендуется без крайней необходимости |
---
## Что такое `Accessor` и зачем он нужен
`Accessor<T, R>` — это **магическая вспомогательная функция**, которая позволяет безопасно обращаться к значениям в состоянии `store`, особенно когда путь к данным содержит **динамическую часть** (например, индекс массива или ID).
---
### Сигнатура
```ts
export type Accessor<T, R = any> = ($: T, t: <K>(arg: K) => K) => R;
```
- `$` — сам `store` (его `proxy`-версия, например `store.$`)
- `t(...)` — специальная обертка для **динамических значений** в пути
На первый взгляд выглядит странно, но суть очень простая:
- Ты пишешь обычный доступ к данным, используя `store.$`, например:
```ts
store.$.items[2];
```
- А теперь представь, что `2` — это переменная `index`, которая может меняться:
```ts
const accessor = ($, t) => $.items[t(index)];
```
🔮 t(...) — просто обёртка, которая говорит системе:
> «Эта часть пути — динамическая, её нужно сохранить как выражение, а потом превратить в строку.»
---
### Что происходит под капотом
- `Accessor` никак не исполняется напрямую.
- Вместо этого `store` вызывает `toString()` на `Accessor`, и получает строку вроде:
```ts
'items.5';
```
- Это делается через **анализ тела функции и регулярки**:
- Внутри `t(index)` → `5`
- `$.items[t(index)]` превращается в `"items.5"`
> 💡 Это очень лёгкий способ выразить динамические пути **без генерации миллионов типов и union'ов**, что делает работу в VSCode быстрой и безопасной.
---
### Примеры
```ts
const index = 1;
store.update(($, t) => $.items[t(index)], 999);
// Аналогично: store.update("items.1", 999)
```
```ts
store.get(($, t) => $.user.settings[t('locale')]);
// Аналогично: store.get("user.settings.locale")
```
```ts
store.subscribeToPath(
($, t) => $.items[t(dynamicIndex)],
(val) => {
console.log('Изменился элемент массива:', val);
}
);
```
---
### Почему это лучше, чем `store.update("items." + index, ...)`
- ✅ Работает с автодополнением
- ✅ Проверяется типами (`PathExtract`, `AssertValidPath`)
- ✅ Не требует ручной склейки строк
- ✅ Не нагружает IDE (в отличие от большого количества вложенных `union`-типов)
---
## ⚠️ На заметку
- Функция `Accessor` используется **только как сигнатура и для парсинга**, она не вызывает `store.$` реально.
- Не стоит использовать сложные условия внутри неё — только прямой доступ через `t(...)`.
---
## Что такое `CacheKeys` и зачем они нужен
`cacheKeys` — это **специальное поле в состоянии `store`**, которое позволяет задавать **кастомные пути для кеширования или подписки**, в том числе с **автодополнением и типовой безопасностью**.
---
### Для чего это нужно
Иногда нам нужно явно указать, **какие части состояния можно инвалидировать, подписывать или использовать в автодополнении**, особенно если они **формируются динамически** или зависят от логики.
В таких случаях на помощь приходит `cacheKeys` — объект, в котором мы описываем возможные "виртуальные" пути, которые будут:
- ✅ поддерживать **автодополнение**
- ✅ работать с методами `store.invalidate(...)`, `store.subscribeToPath(...)` и др.
- ✅ проверяться **типами** при использовании
---
### Пример
```ts
interface StoreState {
items: number[];
counter: number;
cacheKeys?: {
'lol.items': number[]; // <- виртуальный путь
};
}
const initialState = {
items: [1, 2, 3],
counter: 0,
};
export const store = createObservableStore<StoreState>(initialState, []);
```
Теперь можно использовать путь `"cacheKeys.lol.items.0"` в методах:
```ts
store.invalidate('cacheKeys.lol.items.0');
```
> ☝️ Это путь не к реальным данным, а к **виртуальному представлению**, которое вы определили в `cacheKeys`.
---
### Автодополнение
Добавив ключ в `cacheKeys`, вы получаете:
- ✅ Поддержку автодополнения в строках путей
- ✅ Подсказки по индексам (если это массив)
- ✅ Безопасность при работе с методами `invalidate`, `subscribeToPath`, `get` и др.
---
### Как это работает под капотом
- `cacheKeys` — это просто поле в типе состояния, которое **не обязательно должно существовать в `initialState`**.
- Оно используется **только для построения типа возможных путей**.
- Вы можете задавать вложенные объекты и массивы, чтобы IDE и типы могли "понять", что доступно по строковому пути.
---
### Пример с подпиской
```ts
store.subscribeToPath('cacheKeys.lol.items.2', (val) => {
console.log('Элемент lol.items[2] изменился:', val);
});
```
---
### Зачем использовать `cacheKeys`, если можно напрямую
```ts
store.invalidate('lol.items.0'); // ❌ Может не существовать или не иметь автодополнения
```
**Вместо:**
```ts
store.invalidate('cacheKeys.lol.items.0');
```
`cacheKeys` позволяет:
- ✅ Явно указать, какие ключи **имеют смысл**
- ✅ Сделать систему **расширяемой и предсказуемой**
- ✅ Избежать магии и багов, связанных с "плавающими" строками
---
## ⚠️ На заметку
- `cacheKeys` — **исключительно типовая конструкция**, не обязательная в `initialState`
- Работает даже если `cacheKeys` нет в runtime, **главное — чтобы она была в интерфейсе `StoreState`**
- Можно использовать вместе с `Accessor`, если путь динамический:
```ts
const idx = 1;
store.invalidate((t) => store.$['cacheKeys'].lol.items[t(idx)]);
```
## Основные преимущества такого подхода
1. **Фреймворк-агностичность**
- Ядро стора написано «чисто» на TypeScript, без зависимостей от React/Vue/Svelte.
- Для каждого фреймворка достаточно написать адаптер (хук или плагин), который будет цепляться к `store.subscribeToPath` и диспатчить обновления UI.
2. **Точная гранулярность подписок**
- Подписки могут работать по строковому пути или через Accessor<T>, где внутри Accessor можно использовать функцию `t(…)` для динамических индексов.
- Подписчики получают уведомления только по тем полям, на которые они подписаны.
3. **Middleware и валидаторы**
- Можно централизованно описать проверки/блокировки/трансформации значений до их записи.
- Каждый middleware может модифицировать `value` или полностью отменить обновление.
4. **Асинхронная логика ввода-вывода**
- `asyncUpdate` с опцией `abortPrevious` позволяет элегантно обрабатывать взаимодействие с сетью, отменяя прежние запросы, если они больше не актуальны.
5. **История, undo/redo**
- Автоматический стек изменений для каждого пути. Удобно в UI для кнопок «отменить»/«вернуть».
6. **Batching**
- Позволяет сгруппировать сразу несколько взаимосвязанных обновлений, чтобы подписчики получили единое уведомление, и UI не перерендеривался по каждому мелкому изменению.
7. **Полная поддержка TypeScript**
- Тип `Accessor<T> = (t: (arg: any) => any) => T` обеспечивает автодополнение и статическую проверку при работе с вложенными путями.
- Вызовы `store.get` и `store.update` с Accessor’ом позволяют точно указывать нужное свойство без хардкода строк.
---
## Вывод
- **ObservableStore** — это универсальный реактивный стор, построенный на основе JavaScript Proxy, Accessor<T> для динамических путей, granular подписок и цепочек middleware.
- Благодаря «чистому» ядру, написанному на TypeScript, его можно без изменений подключать в React, Vue 3, Svelte, Solid и другие среды: достаточно написать лёгкие адаптеры для подписки и рендеринга.
- **Ключевые возможности**:
1. Поддержка динамических путей через `Accessor<T>`, где внутри можно вызвать `t(index)` для вычисления индекса.
2. Гранулярные подписки по точечному пути или Accessor’у.
3. Middleware для валидации и логирования.
4. Асинхронные обновления с отменой прошлых запросов (`asyncUpdate`).
5. История изменений (undo/redo) для каждого пути.
6. Бэчинг (`batch`) для группировки изменений.
Если вам нужен лёгкий, быстро работающий, максимально гибкий реактивный стор с поддержкой динамических Accessor’ов, изложенный **ObservableStore** предоставит все механизмы «из коробки».