Daily MaverickThe likely Agoa extension is positive for South Africa’s agriculturePunchOsun poll: Your strides paid off, NGF hails Adeleke’s re-electionCNN TürkTürkiye ve 7 ülkeden İsrail'e tepkiESPN DeportesFalla el relevo de Jays, Yankees se despega en la séptima con producción de CaballeroThe Jerusalem PostJewish educators should stop relying on archaeology to prove Israel’s history - opinionוואלהארה"ב שוקלת סנקציות על שופט בברזילESPNPitcher, surgery pioneer Tommy John dies at 83RTP DesportoPichardo sente a idade mas já tem agenda até Los Angeles2028InquirerSenators should explain P24-billion ‘leadership fund’ – AbanteSky TG24Sardegna, barchino di migranti travolge ragazza su un sup durante sbarco: 18enne grave
The Daily Newsstand · Free, Always
Sunday, August 16, 2026

Когда setState недостаточно: как я написал транзакционный стейт-менеджер на TypeScript

Translate

Стейт-менеджеров в JavaScript уже столько, что идея написать ещё один выглядит немного сомнительно. Есть Redux Toolkit, Zustand, MobX, Jotai, XState и десятки менее известных библиотек. Почти для любого способа хранить состояние уже существует готовое решение. Но все же в качестве эксперимента, решил реализовать свою идею.

Изначально я не собирался делать ещё один store с методами getState, setState и subscribe.

Меня интересовала другая проблема. Во многих приложениях изменение состояния является не одной операцией, а целым сценарием:

  1. зарезервировать товар;

  2. списать деньги;

  3. создать заказ;

  4. отправить уведомление;

  5. обновить интерфейс.

Пока всё работает, такой код выглядит как обычная последовательность await. Но если третий шаг падает, приложение оказывается в промежуточном состоянии. Товар уже зарезервирован, деньги списаны, а заказа нет.

Можно написать несколько try/catch, вручную запомнить выполненные действия и откатить их в обратном порядке. Потом добавить повторные попытки, таймауты, логирование, подписки и историю изменений. В какой-то момент обычная функция превращается в маленький workflow engine.

Так появился Staga, TypeScript-библиотека, которая объединяет реактивное состояние и многошаговые операции с компенсацией.

В этой статье разберу, зачем это нужно, как устроен пакет внутри, где заканчивается обычный state management и почему слово «транзакция» здесь нужно использовать осторожно.

TL;DR

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

Идея 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 означает две дополнительные попытки после первой. Всего функция может быть вызвана три раза.

Полный пример: checkout с откатом

Ниже пример, близкий к реальному сценарию. В состоянии мы храним только данные, которые нужны приложению для отображения процесса.

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 и compensation

На первый взгляд кажется, что достаточно одного механизма. На практике они решают разные задачи.

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

Упрощённо реактивный 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, выражение ниже будет возвращать разные ссылки:

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 должен корректно обрабатывать такие изменения и не терять реактивность для вложенных значений.

Уведомление не равно commit

Во время мутаций подписчики могут получать промежуточные изменения. После завершения шага итоговое состояние дополнительно передаётся в основной 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-цикла.

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-шаги лучше строить так:

  1. сначала получить внешний результат;

  2. затем одним коротким блоком изменить локальное состояние;

  3. не мутировать state до операции, которая может завершиться ошибкой.

Timeout не останавливает код автоматически

Для таймаута обычно используется гонка между операцией и таймером:

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

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-процесса на другой машине.

Undo, redo и snapshots

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 и экземпляров классов лучше явно определить стратегию.

Запись и replay

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.

Как я тестирую rollback

Для такого пакета 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 не расходились после ошибки.

Чем Staga отличается от обычного state manager

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 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.

Ссылки

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.