Когда setState недостаточно: как я написал транзакционный стейт-менеджер на TypeScript
- понедельник, 17 августа 2026 г. в 00:00:05
Стейт-менеджеров в JavaScript уже столько, что идея написать ещё один выглядит немного сомнительно. Есть Redux Toolkit, Zustand, MobX, Jotai, XState и десятки менее известных библиотек. Почти для любого способа хранить состояние уже существует готовое решение. Но все же в качестве эксперимента, решил реализовать свою идею.
Изначально я не собирался делать ещё один store с методами getState, setState и subscribe.
Меня интересовала другая проблема. Во многих приложениях изменение состояния является не одной операцией, а целым сценарием:
зарезервировать товар;
списать деньги;
создать заказ;
отправить уведомление;
обновить интерфейс.
Пока всё работает, такой код выглядит как обычная последовательность await. Но если третий шаг падает, приложение оказывается в промежуточном состоянии. Товар уже зарезервирован, деньги списаны, а заказа нет.
Можно написать несколько try/catch, вручную запомнить выполненные действия и откатить их в обратном порядке. Потом добавить повторные попытки, таймауты, логирование, подписки и историю изменений. В какой-то момент обычная функция превращается в маленький workflow engine.
Так появился Staga, TypeScript-библиотека, которая объединяет реактивное состояние и многошаговые операции с компенсацией.
В этой статье разберу, зачем это нужно, как устроен пакет внутри, где заканчивается обычный state management и почему слово «транзакция» здесь нужно использовать осторожно.
Staga позволяет описать операцию как последовательность шагов. У каждого шага есть основная функция и необязательная компенсация:
import { SagaManager } from '@staga/core'; type State = { balance: number; orders: string[]; }; const saga = SagaManager.create<State>({ balance: 1000, orders: [], }); const purchase = saga .createTransaction<{ orderId: string; amount: number }>('purchase') .addStep( 'debit', (state, payload) => { state.balance -= payload.amount; }, (state, payload) => { state.balance += payload.amount; }, ) .addStep( 'create-order', (state, payload) => { state.orders.push(payload.orderId); }, (state, payload) => { state.orders = state.orders.filter(id => id !== payload.orderId); }, ); await purchase.run({ orderId: 'order-42', amount: 100 });
Если второй шаг завершится ошибкой, успешно выполненные шаги будут компенсированы в обратном порядке, а локальное состояние вернётся к снимку, созданному перед запуском транзакции.
Кроме этого, в пакете есть:
реактивные селекторы;
undo/redo и снимки состояния;
retries и таймауты для отдельных шагов;
типизированные события;
middleware;
запись и воспроизведение событий;
небольшой низкоуровневый реактивный модуль statekit.
При этом Staga работает внутри одного процесса. Она не сохраняет выполнение после перезапуска сервера и не заменяет базу данных или Temporal.
Простейший state manager можно написать за несколько минут:
class Store<T> { private state: T; private listeners = new Set<(state: T) => void>(); constructor(initialState: T) { this.state = initialState; } getState(): T { return this.state; } setState(nextState: T): void { this.state = nextState; for (const listener of this.listeners) { listener(this.state); } } subscribe(listener: (state: T) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } }
Для многих приложений этого достаточно. Сложность начинается не тогда, когда нужно сохранить новое значение, а тогда, когда одно бизнес-действие меняет состояние несколько раз и вызывает внешние сервисы.
Рассмотрим обычный checkout:
async function checkout(input: CheckoutInput) { const reservation = await inventory.reserve(input.items); const payment = await payments.charge(input.amount); const order = await orders.create(input); return order; }
У этой функции есть несколько неприятных сценариев:
резерв создан, но платёж не прошёл;
платёж прошёл, но создание заказа упало;
запрос к платёжному сервису завис;
процесс получил временную сетевую ошибку;
повторный запуск списал деньги второй раз;
UI успел получить промежуточное состояние.
Обычный store не решает эти проблемы. Он умеет хранить результат, но ничего не знает о жизненном цикле операции, порядке шагов и правилах восстановления.
Идея saga появилась не в Redux и не во фронтенде. Её описали Гектор Гарсия-Молина и Кеннет Салем в работе SAGAS в 1987 году.
Суть идеи довольно простая. Длинная операция разбивается на последовательность небольших транзакций:
T1 -> T2 -> T3 -> T4
Для каждой выполненной операции может существовать компенсирующее действие:
C1 <- C2 <- C3 <- C4
Если T3 завершилась ошибкой, система запускает компенсации уже выполненных шагов в обратном порядке:
T1 -> T2 -> T3 X C2 <- C1
Компенсация не обязательно возвращает мир в абсолютно прежнее состояние. Например, отправленное письмо нельзя «отправить обратно». Можно только отправить другое письмо, пометить первое как неактуальное или записать корректирующее событие.
Это важное отличие saga от транзакции базы данных.
Перед реализацией я зафиксировал несколько требований.
Во-первых, состояние и payload должны быть полностью типизированы. Если транзакция принимает { orderId: string }, все шаги должны получать именно этот тип.
Во-вторых, сценарий должен читаться сверху вниз как обычный бизнес-процесс. Без генераторов, отдельных action type и большого количества служебного кода.
В-третьих, локальное состояние должно восстанавливаться автоматически, даже если для какого-то шага не была написана компенсация.
В-четвёртых, компенсации должны выполняться в обратном порядке.
В-пятых, retry, timeout, события и middleware не должны смешиваться с бизнес-логикой каждого шага.
Получился следующий API:
const transaction = saga .createTransaction<Payload>('transaction-name') .addStep( 'step-name', async (state, payload) => { // выполнить шаг }, async (state, payload) => { // компенсировать шаг }, { retries: 2, timeout: 3000, }, ); await transaction.run(payload);
Здесь retries: 2 означает две дополнительные попытки после первой. Всего функция может быть вызвана три раза.
Ниже пример, близкий к реальному сценарию. В состоянии мы храним только данные, которые нужны приложению для отображения процесса.
import { SagaManager, createLoggingMiddleware, createTimingMiddleware, } from '@staga/core'; type CheckoutState = { status: 'idle' | 'processing' | 'completed' | 'failed'; reservationId: string | null; paymentId: string | null; orderId: string | null; error: string | null; }; type CheckoutPayload = { requestId: string; userId: string; items: Array<{ sku: string; quantity: number; }>; amount: number; }; const saga = SagaManager.create<CheckoutState>({ status: 'idle', reservationId: null, paymentId: null, orderId: null, error: null, }); saga.use(createLoggingMiddleware()); saga.use( createTimingMiddleware((transactionName, duration) => { console.log(`${transactionName}: ${duration} ms`); }), );
Теперь опишем шаги.
const checkout = saga .createTransaction<CheckoutPayload>('checkout') .addStep( 'start', state => { state.status = 'processing'; state.error = null; }, state => { state.status = 'idle'; }, ) .addStep( 'reserve-stock', async (state, payload) => { const reservation = await inventory.reserve({ items: payload.items, idempotencyKey: `reserve:${payload.requestId}`, }); state.reservationId = reservation.id; }, async state => { if (!state.reservationId) { return; } await inventory.release(state.reservationId); state.reservationId = null; }, { retries: 2, timeout: 3000, }, ) .addStep( 'charge-payment', async (state, payload) => { const payment = await payments.charge({ userId: payload.userId, amount: payload.amount, idempotencyKey: `charge:${payload.requestId}`, }); state.paymentId = payment.id; }, async (state, payload) => { if (!state.paymentId) { return; } await payments.refund({ paymentId: state.paymentId, idempotencyKey: `refund:${payload.requestId}`, }); state.paymentId = null; }, { timeout: 5000, }, ) .addStep( 'create-order', async (state, payload) => { const order = await orders.create({ userId: payload.userId, items: payload.items, paymentId: state.paymentId, reservationId: state.reservationId, idempotencyKey: `order:${payload.requestId}`, }); state.orderId = order.id; }, async state => { if (!state.orderId) { return; } await orders.cancel(state.orderId); state.orderId = null; }, ) .addStep('complete', state => { state.status = 'completed'; });
Запуск выглядит так:
try { await checkout.run({ requestId: crypto.randomUUID(), userId: 'user-42', items: [ { sku: 'keyboard', quantity: 1 }, { sku: 'mouse', quantity: 1 }, ], amount: 15000, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); saga.stateManager.setState({ ...saga.getState(), status: 'failed', error: message, }); }
Допустим, резервирование и платёж прошли, но orders.create() выбросил ошибку.
Staga выполнит следующее:
start success reserve-stock success charge-payment success create-order failed rollback: charge-payment refund reserve-stock release start status = idle restore snapshot
Сначала запускаются компенсации успешно выполненных шагов в обратном порядке. Затем локальное состояние восстанавливается из снимка, созданного перед транзакцией.
После этого внешний catch может записать уже новое состояние ошибки. Это полезно, потому что сама транзакция отвечает за восстановление данных, а вызывающий код решает, что показывать пользователю.
На первый взгляд кажется, что достаточно одного механизма. На практике они решают разные задачи.
Snapshot восстанавливает локальное состояние:
{ reservationId: null, paymentId: null, orderId: null }
Но snapshot ничего не может сделать с внешним миром. Если платёжный сервис уже списал деньги, простое изменение JavaScript-объекта их не вернёт.
Для этого нужна компенсация:
async (state, payload) => { await payments.refund({ paymentId: state.paymentId, idempotencyKey: `refund:${payload.requestId}`, }); }
И наоборот, одной компенсации тоже недостаточно. Разработчик может забыть очистить одно из локальных полей или компенсирующий обработчик может изменить не все данные. Финальный snapshot служит дополнительной страховкой для состояния внутри процесса.
Поэтому схема выглядит так:
external side effects -> compensate() local state -> restore snapshot
Staga не зависит от React, Vue или Angular. В основе лежат обычные подписки и сигналы.
const status$ = saga.select(state => state.status); console.log(status$.value); const unsubscribe = status$.subscribe(status => { console.log('checkout status:', status); });
Селектор получает текущее значение через .value или .get() и позволяет подписаться через .subscribe().
Можно выбрать отдельное свойство:
const orderId$ = saga.selectProperty('orderId');
Или объединить несколько частей состояния:
const summary$ = saga.stateManager.combine( state => state.status, state => state.orderId, (status, orderId) => ({ status, orderId }), );
Это позволяет писать небольшие адаптеры для любого UI-фреймворка, не включая их в core-пакет.
На верхнем уровне архитектура выглядит так:
+--------------------+ | SagaManager | +----------+---------+ | +-------------------------+-------------------------+ | | | v v v +-------------------+ +-------------------+ +-------------------+ | Transaction | | StateManager | | EventManager | +---------+---------+ +---------+---------+ +-------------------+ | | v v +-------------------+ +-------------------+ | statekit tx core | | statekit Store | | retries/timeouts | | Signals/Streams | +---------+---------+ +---------+---------+ | | +-------------+-----------+ v +--------------------+ | ReactiveStateProxy | +--------------------+
SagaManager является фасадом. Через него создаются транзакции, селекторы, подписки, middleware и запись событий.
StateManager хранит состояние, историю undo/redo, snapshots и связывает их с реактивным Store.
Transaction отвечает за бизнес-шаги, создание исходного снимка, порядок компенсаций и преобразование низкоуровневых событий в события Staga.
ReactiveStateProxy позволяет писать обычные мутации:
state.items.push(item); state.count += 1;
Внутри они отслеживаются через JavaScript Proxy.
Наконец, statekit содержит минимальные примитивы: store, signals, streams, batching, scheduler и низкоуровневый исполнитель транзакций.
Упрощённо реактивный store устроен так:
type Unsubscribe = () => void; type EqualityFn<T> = (a: T, b: T) => boolean; class Store<S extends object> { private state: S; private listeners = new Set<() => void>(); constructor(initialState: S) { this.state = initialState; } getState(): S { return this.state; } setState( updater: Partial<S> | ((previous: S) => S | Partial<S>), ): void { const previous = this.state; const patch = typeof updater === 'function' ? updater(previous) : updater; const next = patch === previous ? patch as S : { ...previous, ...patch }; if (Object.is(previous, next)) { return; } this.state = next; this.emit(); } subscribe(listener: () => void): Unsubscribe { this.listeners.add(listener); return () => this.listeners.delete(listener); } watch<T>( selector: (state: S) => T, callback: (next: T, previous: T) => void, equality: EqualityFn<T> = Object.is, ): Unsubscribe { let previous = selector(this.state); return this.subscribe(() => { const next = selector(this.state); if (equality(previous, next)) { return; } const oldValue = previous; previous = next; callback(next, oldValue); }); } private emit(): void { for (const listener of [...this.listeners]) { listener(); } } }
В реальном statekit.Store дополнительно есть:
middleware до и после изменения состояния;
batching;
приоритеты обновлений через scheduler;
отдельные middleware для событий транзакций;
полная замена состояния без merge-семантики.
Последний пункт нужен для undo, redo и rollback. При восстановлении снимка необходимо заменить всё состояние, а не объединить его с текущим объектом.
Для селекторов я использовал небольшой signal-граф.
Обычный signal хранит значение и список подписчиков:
class SignalNode<T> { private subscribers = new Set<() => void>(); constructor(private value: T) {} get(): T { track(this); return this.value; } set(next: T): void { if (Object.is(this.value, next)) { return; } this.value = next; for (const subscriber of [...this.subscribers]) { subscriber(); } } subscribe(subscriber: () => void): () => void { this.subscribers.add(subscriber); return () => this.subscribers.delete(subscriber); } }
Самая интересная часть начинается с derived-сигналов:
const firstName = signal('Ada'); const lastName = signal('Lovelace'); const fullName = derived( () => `${firstName.get()} ${lastName.get()}`, );
Во время выполнения compute() библиотека запоминает все сигналы, у которых был вызван get(). После этого derived подписывается на найденные зависимости.
Зависимости могут быть динамическими:
const usePrimary = signal(true); const primary = signal('primary'); const secondary = signal('secondary'); const selected = derived(() => { return usePrimary.get() ? primary.get() : secondary.get(); });
Когда usePrimary меняется, derived должен отписаться от старого набора сигналов и подписаться на новый. Иначе он продолжит реагировать на уже неиспользуемую зависимость или, наоборот, перестанет обновляться.
Для этого при каждом вычислении собирается новый набор зависимостей, затем он сравнивается с предыдущим:
for (const [dependency, unsubscribe] of subscriptions) { if (!nextDependencies.has(dependency)) { unsubscribe(); subscriptions.delete(dependency); } } for (const dependency of nextDependencies) { if (!subscriptions.has(dependency)) { subscriptions.set( dependency, dependency.subscribe(recompute), ); } }
Это небольшой механизм, но именно он делает вычисляемые значения действительно реактивными.
Пользовательский код транзакции выглядит так:
state.users.push(user); state.count += 1;
Сам state при этом оборачивается в Proxy.
Упрощённая версия выглядит следующим образом:
function createReactiveProxy<T extends object>( target: T, notify: () => void, ): T { return new Proxy(target, { get(object, property, receiver) { const value = Reflect.get(object, property, receiver); if (value !== null && typeof value === 'object') { return createReactiveProxy(value, notify); } return value; }, set(object, property, value) { const previous = Reflect.get(object, property); const changed = !Object.is(previous, value); const result = Reflect.set(object, property, value); if (result && changed) { notify(); } return result; }, }); }
В production-реализации есть несколько дополнительных деталей.
Если при каждом чтении вложенного объекта создавать новый Proxy, выражение ниже будет возвращать разные ссылки:
proxy.user === proxy.user; // false
Это ломает equality checks и создаёт лишние объекты. Поэтому Staga хранит соответствие original object -> proxy в WeakMap.
private proxyCache = new WeakMap<object, object>();
Повторный доступ к одному объекту возвращает тот же proxy.
Вызов state.items.push(item) внутри изменяет несколько свойств массива, включая индекс и length. Proxy должен корректно обрабатывать такие изменения и не терять реактивность для вложенных значений.
Во время мутаций подписчики могут получать промежуточные изменения. После завершения шага итоговое состояние дополнительно передаётся в основной Store, чтобы обновились selectors и signals.
Это означает, что Staga не предоставляет isolation в смысле базы данных. Она умеет восстановить финальное локальное состояние, но не скрывает все промежуточные значения от наблюдателей.
В сильно упрощённом виде алгоритм выглядит так:
async function runTransaction<State, Payload>( state: State, payload: Payload, steps: Step<State, Payload>[], ) { const snapshot = clone(state); const completed: Step<State, Payload>[] = []; try { for (const step of steps) { await executeWithRetryAndTimeout( () => step.execute(state, payload), step.options, ); completed.push(step); } } catch (error) { for (const step of completed.reverse()) { await step.compensate?.(state, payload); } restore(snapshot); throw error; } }
В Staga реализация разделена на два уровня.
Низкоуровневый statekit executor отвечает за:
порядок запуска шагов;
количество попыток;
timeout;
низкоуровневые события;
AbortController внутри transaction core.
Высокоуровневый Transaction отвечает за:
snapshot исходного состояния;
создание reactive proxy;
типизированный payload;
вызов compensation в обратном порядке;
восстановление StateManager;
преобразование событий в публичный API;
выполнение middleware.
Такое разделение оказалось полезным. Низкоуровневый executor можно использовать отдельно, а публичный API не зависит от деталей retry-цикла.
Повторить функцию несложно:
async function retry<T>( operation: () => Promise<T>, retries: number, ): Promise<T> { let attempt = 0; while (true) { try { return await operation(); } catch (error) { if (attempt >= retries) { throw error; } attempt += 1; } } }
Проблема не в цикле, а в семантике операции.
Рассмотрим такой шаг:
.addStep( 'charge', async state => { const payment = await payments.charge(100); state.paymentId = payment.id; throw new Error('network response parsing failed'); }, undefined, { retries: 2 }, )
Платёж мог пройти, хотя функция завершилась ошибкой. Следующая попытка способна списать деньги повторно.
Поэтому retry безопасен только при выполнении хотя бы одного условия:
операция идемпотентна;
внешний сервис поддерживает idempotency key;
шаг умеет определить, был ли предыдущий вызов выполнен;
повторение происходит только для ошибок, которые гарантированно возникли до side effect.
Именно поэтому в checkout-примере используется requestId:
idempotencyKey: `charge:${payload.requestId}`
Staga может повторить функцию, но не может сделать произвольный внешний API идемпотентным.
Также важно, что неуспешная попытка может успеть изменить локальный объект до выбрасывания ошибки. Финальный rollback восстановит snapshot, но следующая попытка будет запущена в том же процессе. Поэтому retryable-шаги лучше строить так:
сначала получить внешний результат;
затем одним коротким блоком изменить локальное состояние;
не мутировать state до операции, которая может завершиться ошибкой.
Для таймаута обычно используется гонка между операцией и таймером:
function withTimeout<T>( operation: Promise<T>, timeoutMs: number, ): Promise<T> { return Promise.race([ operation, new Promise<never>((_, reject) => { setTimeout(() => { reject(new Error(`Timeout after ${timeoutMs} ms`)); }, timeoutMs); }), ]); }
После истечения времени вызывающий код получает ошибку. Но исходный Promise продолжает выполняться, если сама операция не поддерживает отмену.
Например, такой код не гарантирует, что запрос действительно остановлен:
await withTimeout(fetch('/api/payment'), 1000);
Чтобы реально прервать fetch, нужно передать AbortSignal:
const controller = new AbortController(); await fetch('/api/payment', { signal: controller.signal, }); controller.abort();
Поэтому timeout в Staga стоит воспринимать как ограничение времени ожидания, а не как универсальное физическое завершение операции. Внешние вызовы всё равно должны поддерживать cancellation или быть безопасными при позднем завершении.
Транзакция генерирует типизированные события:
transaction:start step:start step:success step:retry transaction:success transaction:fail step:rollback transaction:rollback
Подписка выглядит так:
const unsubscribeRetry = saga.onSagaEvent( 'step:retry', event => { console.log({ step: event.stepName, attempt: event.attempt, error: event.lastError.message, }); }, );
Можно слушать все события:
const unsubscribeAll = saga.onAnyEvent(event => { console.log(event.type, event.timestamp); });
Или получить Stream:
const failures = saga.onEventStream('transaction:fail'); const unsubscribe = failures.subscribe(event => { console.error(event); });
На этом API можно построить логирование, метрики, devtools или интеграцию с OpenTelemetry.
Middleware оборачивает всю транзакцию по тому же принципу, что и Koa:
const middleware = async (context, next) => { console.log('before', context.transaction.name); await next(); console.log('after', context.transaction.name); }; saga.use(middleware);
В пакете есть готовые middleware для логирования, измерения времени и сохранения состояния в localStorage.
saga.use(createLoggingMiddleware()); saga.use( createTimingMiddleware((name, duration, state) => { metrics.observe('staga_transaction_duration', duration, { transaction: name, }); }), );
Persistence middleware запускается после успешного завершения транзакции:
saga.use( createPersistenceMiddleware('checkout-state'), );
Это удобный механизм для браузерных приложений, но он не является durable storage для серверных workflow. localStorage не поможет после падения Node.js-процесса на другой машине.
StateManager можно использовать отдельно от saga:
import { StateManager } from '@staga/core'; const state = new StateManager( { count: 0 }, { maxUndoHistory: 50, maxSnapshots: 10, snapshotInterval: 5, }, ); state.setState({ count: 1 }); state.setState({ count: 2 }); state.undo(); console.log(state.getState()); // { count: 1 } state.redo(); console.log(state.getState()); // { count: 2 }
Для transaction-style мутаций успешная операция добавляет исходное состояние в undo history как одну логическую точку возврата.
Snapshots отличаются от undo history. Undo нужен пользователю или приложению для пошагового движения назад. Snapshot используется как техническая точка восстановления транзакции.
По умолчанию история ограничена, чтобы не расти бесконечно. Также можно передать собственные функции клонирования и сравнения.
Это особенно важно для нестандартных значений:
const state = new StateManager(initialState, { clone: value => structuredClone(value), equalityFn: (a, b) => deepEqual(a, b), });
Встроенное клонирование рассчитано в первую очередь на обычные объекты и массивы. Для Date, Map, Set и экземпляров классов лучше явно определить стратегию.
Staga может записывать изменения состояния и события:
saga.startRecording(); await checkout.run(payload); saga.stopRecording(); const events = saga.getRecordedEvents();
Затем их можно воспроизвести:
await saga.startReplay({ delay: 50 });
Это полезно для демо, визуальной отладки и анализа последовательности событий.
Но здесь важно не переоценивать механизм. Текущий replay не является полноценным event sourcing и не делает выполнение устойчивым к падению процесса. Запись хранится в памяти, а воспроизведение повторяет сохранённые state changes и события. Внешние API-вызовы таким способом не восстанавливаются.
Для настоящего durable execution понадобятся постоянный журнал, versioning событий, детерминированное восстановление и отдельный runtime.
Для такого пакета happy path является самой простой частью. Основные тесты нужны вокруг ошибок.
Например, проверка обратного порядка компенсаций:
import { describe, expect, it } from 'vitest'; import { SagaManager } from '@staga/core'; describe('transaction rollback', () => { it('compensates completed steps in reverse order', async () => { const saga = SagaManager.create({ count: 0 }); const rollbackOrder: string[] = []; const transaction = saga .createTransaction<void>('test') .addStep( 'first', state => { state.count += 1; }, state => { rollbackOrder.push('first'); state.count -= 1; }, ) .addStep( 'second', state => { state.count += 10; }, state => { rollbackOrder.push('second'); state.count -= 10; }, ) .addStep('fail', () => { throw new Error('boom'); }); await expect( transaction.run(undefined), ).rejects.toThrow('rolled back'); expect(rollbackOrder).toEqual([ 'second', 'first', ]); expect(saga.getState()).toEqual({ count: 0, }); }); });
Кроме этого, стоит проверять:
ошибка на первом шаге;
ошибка после нескольких успешных шагов;
compensation, которая сама падает;
timeout;
точное число retry;
undo после успешной транзакции;
обновление selectors после rollback;
вложенные мутации массивов и объектов;
одновременный запуск нескольких транзакций;
очистку подписок и таймеров.
Именно edge cases определяют качество такого инструмента. Реализовать setState легко. Гораздо сложнее сделать так, чтобы история, proxy, selectors и rollback не расходились после ошибки.
Redux Toolkit и Zustand отлично решают задачу хранения состояния и реакции интерфейса на изменения. Redux Saga и listener middleware помогают оркестрировать side effects. Workflow engines решают долгоживущие и распределённые процессы.
Staga занимает более узкую промежуточную нишу:
Инструмент | Основная задача | Компенсация шагов |
|---|---|---|
Redux Toolkit / Zustand | Состояние приложения | Вручную |
Redux Saga | Оркестрация side effects вокруг Redux | Вручную |
Staga | Локальное состояние плюс многошаговые операции | Есть |
Temporal и похожие системы | Durable workflows | Есть на уровне workflow-паттернов |
Я бы не стал переносить большое существующее Redux-приложение на Staga только ради другого API.
Библиотека полезнее там, где много операций вида:
сделать A затем B затем C если что-то упало, компенсировать выполненное
Например:
checkout;
мастер настройки приложения;
создание пользователя и его ресурсов;
локальный deployment pipeline;
многошаговая форма;
управление инструментами AI-агента внутри одного процесса;
последовательность изменений редактора с undo/redo.
Главный вывод оказался довольно неожиданным. Самая простая часть state manager заключается в хранении state.
Настоящие сложности находятся вокруг времени:
когда подписчик должен увидеть изменение;
считать ли несколько мутаций одной операцией;
что делать с уже выполненными шагами;
как повторять операцию безопасно;
что произойдёт после timeout;
в каком порядке запускать compensation;
как синхронизировать rollback с selectors;
что делать, если восстановление тоже завершилось ошибкой.
То есть state management довольно быстро превращается в управление переходами и жизненным циклом операций.
Именно поэтому я оставил в Staga два уровня.
Низкоуровневый statekit остаётся небольшим набором реактивных примитивов. Поверх него SagaManager добавляет транзакции, события, middleware и восстановление.
Так библиотеку можно использовать как обычный store, как transaction runner или как сочетание обоих подходов.
Staga не пытается заменить все state managers и workflow engines сразу.
Это библиотека для случая, когда приложению мало просто изменить state. Нужно выполнить несколько связанных действий, наблюдать за их выполнением и восстановить локальное состояние после ошибки.
Минимальный пример по-прежнему выглядит достаточно компактно:
const transaction = saga .createTransaction<Payload>('operation') .addStep('first', executeFirst, compensateFirst) .addStep('second', executeSecond, compensateSecond, { retries: 2, timeout: 3000, }); await transaction.run(payload);
Но за этим API скрываются snapshots, proxy, reactive selectors, retry-цикл, таймеры, middleware, события и обратный порядок компенсаций.
Для меня этот проект стал хорошим способом глубже разобраться не только в state management, но и в том, как приложение ведёт себя между двумя устойчивыми состояниями, когда часть работы уже выполнена, а часть ещё может упасть.
Исходники открыты, пакет не имеет runtime-зависимостей и опубликован под MIT.