Перейти к содержимому
GitHubXDiscord

Сравнения

Triggery пересекается с кучей библиотек из мира React/Redux/RxJS. Большинство из них лучше Triggery в том, для чего они были спроектированы. Эта страница — честный путеводитель в формате «выбирай X, если…».

Принцип везде один: у каждой библиотеки есть своя главная задача, и у Triggery — тоже своя. Когда задачи разные — библиотеки сосуществуют; когда пересекаются — обычно одна выигрывает.

Бок о бок: один и тот же пятистрочный сценарий

Заголовок раздела «Бок о бок: один и тот же пятистрочный сценарий»

Сценарий — «когда срабатывает messageReceived, если settings.notifications включён, диспатчим showToast». Это канонический пример по всей документации.

Triggery
createTrigger<S>({
  events: ['messageReceived'],
  required: ['settings'],
  handler: ({ event, check, actions }) => {
    if (check.is('settings', s => s.notifications)) actions.showToast?.(event.payload);
  },
});
useEffect
useEffect(() => {
  if (msg && settings.notifications) showToast(msg);
}, [msg, settings.notifications]);
Redux Saga
function* watch() { yield takeLatest('messageReceived', function* (a) {
  const s = yield select(x => x.settings);
  if (s.notifications) yield put(showToast(a.payload));
});}
redux-observable
const epic = (action$, state$) => action$.pipe(
  ofType('messageReceived'),
  withLatestFrom(state$),
  filter(([, s]) => s.settings.notifications),
  map(([a]) => showToast(a.payload)),
);
XState (invoke)
on: { messageReceived: { actions: 'showToastIfEnabled', cond: 'notificationsOn' } }
Effector
sample({ clock: messageReceived, source: $settings, filter: s => s.notifications, fn: (_, p) => p, target: showToast });

Различия ниже объясняют, что из этого тебе стоит выбрать.


useEffect — это хук React общего назначения «сделай это после рендера». Правильный инструмент, чтобы синхронизировать компонент с DOM, ref’ом или плотно связанной подпиской. Неправильный — чтобы оркестрировать побочные эффекты через границы фич. Но его всё равно используют именно так, и это та самая исходная проблема, ради которой и появился Triggery.

Разница в ментальных моделях короткая: useEffect запускается на каждое изменение deps, с cleanup; обработчик Triggery запускается на каждое событие, с ленивыми чтениями условий. Как только правило затрагивает больше одной фичи — массив зависимостей useEffect начинает течь (семантика capture-by-value, ручные useCallback, предупреждения «забыл deps»), а cleanup-функция течёт в обратную сторону.

useEffectTriggery
Ментальная модель«Когда deps меняются — перезапустить с cleanup»«Когда срабатывает это событие и выполнены условия — сделай эти действия»
Владеет состояниемНетНет
Владеет побочными эффектамиInlineВынесены в реакторы
Межкомпонентная координацияВручную (контекст, прокидывание пропсов)Встроенная
Перерендеривает хост при измененииДаНет (условия — pull-only)
Тестируется без рендераНетДа

Выбирай useEffect, если… Побочный эффект плотно связан с одним компонентом, не будет расти, массив зависимостей маленький. Триггеры добавляют файл и концепцию рантайма — для трёхстрочного useEffect это накладные расходы, а не озарение.

Выбирай Triggery, если… Побочный эффект пересекает границу фич, массив зависимостей растёт, или ты хочешь прочитать правило сверху вниз в одном файле. См. миграцию с useEffect — там паттерны.


Саги — это координатор эффектов на генераторах. В 2016 году они были отличным ответом на «как выразить долгий отменяемый асинхронный процесс поверх потока action», и в этом они по-прежнему хороши. Но они плохой ответ на «как выразить сценарий через три фичи» — для этого они обычно превращаются в одну большую корневую сагу, к которой никто не хочет прикасаться.

Главное концептуальное отличие: сага — это генератор, который yield-ит эффекты, а рантайм под ним решает, что значат call, put и select. Триггер — обычная функция, которая получает уже собранный контекст. Генераторы выигрывают там, где нужно выразить долгий многошаговый процесс («возьми это, потом то, потом race»). Обычные функции выигрывают в 90% сценариев вида «одно событие, пара проверок, пара выходов».

SagaTriggery
Ментальная модельГенератор отдаёт эффекты через yieldОбычная функция читает ctx
Длинные многошаговые процессыНативно (yield take, yield race)Вручную (Promise.race, явные циклы)
Отменаcancel(task) или неявно через takeLatestsignal + take-latest (по умолчанию)
Доступ к состояниюyield selectconditions.foo (лениво)
Диспатчyield putactions.foo?.(payload)
Читается как спецификацияНет — сагу читают сверху внизДа — файл триггера и есть спецификация
Эргономика тестовЗрелая (redux-saga-test-plan)Чистая функция с заглушками

Выбирай Redux Saga, если… У тебя длинные процессы с явными шагами yield take / yield race, напоминающие конечный автомат, и команда уже умеет в генераторы.

Выбирай Triggery, если… Большинство твоих «саг» — это watcher-ы одного события (takeLatest плюс маленький воркер). Механическое соответствие — в миграции с Redux Saga. Пока специализированный codemod-скрипт не готов, переносить приходится руками — но для типовых форм это один прицельный sed-проход.


Epics — это RxJS-конвейеры поверх потока action. Где саги используют генераторы, epics используют операторы. Сценарий применения тот же: длинные трансформации потоковой формы.

redux-observableTriggery
Ментальная модельПоток action через операторыОбычная функция реагирует на одно событие
Фильтрация по событиюofType('x')events: ['x'] (с индексом)
switchMap / mergeMap / concatMap / exhaustMapНа уровне оператораconcurrency: 'take-latest' / 'take-every' / 'queue' / 'exhaust'
combineLatest, withLatestFromНативно для потоковconditions (ленивый pull) — у combineLatest для >2 потоков прямого соответствия нет
Backpressure и буферизацияНативные операторыНе предоставляется
РазмерRxJS большой (~30 КБ gzip)Ядро ~5 КБ gzip

Выбирай redux-observable или RxJS, если… Ты собираешь потоки из нескольких источников, нужен backpressure, или есть домен (анимация, real-time-сигналы), где потоки — естественная модель.

Выбирай Triggery, если… Большинство твоих epics — это «фильтр по типу, асинхронщина, диспатч результата». Это и есть триггер. См. миграцию с redux-observable.


То же самое, что redux-observable, только без Redux. RxJS — библиотека реактивных примитивов; Triggery — оркестратор побочных эффектов. Они решают соседние задачи и часто живут в одном приложении.

RxJS-ish — stream
fromEvent(input, 'input').pipe(
  debounceTime(300),
  switchMap(q => from(searchApi(q))),
).subscribe(setResults);
Triggery — orchestration
createTrigger<{ events: { q: string }; actions: { runSearch: string } }>({
  events: ['q'], required: [],
  handler({ event, actions }) {
    actions.debounce(300).runSearch?.(event.payload);
  },
});

Версия RxJS собирает трансформацию данных и эмитит результаты подписчику. Версия Triggery вызывает побочный эффект и оставляет реактору решать, что с ним делать. Если в основном нужны трансформации — выигрывает RxJS. Если в основном нужна оркестрация с кросс-фичной проводкой — выигрывает Triggery.

Выбирай RxJS, если… Нужна богатая композиция потоков (combineLatest, switchMap, share), backpressure, или ты в домене (аудио, сложный realtime), где потоки выигрывают у сценариев.

Выбирай Triggery, если… В основном тянешь «когда X произошло, если Y — сделай Z» через фичи. Triggery делает это меньшим числом понятий и интегрируется с уже имеющимся стором. Они отлично сосуществуют: обработчик триггера может .subscribe() на observable, а epic может dispatch() action, на который слушает триггер.


XState — правильный инструмент для логики конечного автомата: пользователь ходит idle → loading → success / error, с допустимыми переходами, охранными условиями (guards) и действиями entry/exit. Statechart — артефакт, который описывает идентичность машины во времени. Триггеры описывают сценарии: дискретные правила «причина — следствие», не несущие персистентного состояния.

Противоречия тут нет. Обработчик триггера может вызвать send() у сервиса XState, чтобы продвинуть машину; сервис XState может диспатчить action, на который реагирует триггер. Выбор между ними — вопрос «задача про состояние или транзакционная?».

XStateTriggery
Ментальная модельStatechart с явными состояниями и переходамиСобытие → условия → действия, без состояния
Идентичность во времениДа — машина является сущностьюНет — запуски обработчика эфемерны
Охранные условияПредикаты cond:check.is и if в обработчике
Побочные эффектыActions, services (invoke)Реакторы useAction
ВизуализацияМирового класса (statechart visualiser)Граф триггеров (triggery graph) — список или DOT, не диаграмма
Эргономика тестовModel-based testingЧистый обработчик и заглушки

Выбирай XState, если… Задача — конечный автомат. Есть допустимые переходы, есть переходы, которые надо отклонять, и сама диаграмма и есть спецификация.

Выбирай Triggery, если… Задача — «что-то случилось, сделай что-нибудь». Большинство побочных эффектов в приложении такие: тосты, аналитика, debounce-сохранения, оптимистичный UI. Triggery не заменяет XState; он работает рядом.


Effector — это типизированная реактивная библиотека: stores, events, effects, со статически типизированными графами и крошечным рантаймом. С Triggery она пересекается больше остальных, потому что обе целятся в слой оркестрации с typed-first-дизайном. Разница — в направлении реактивности.

Effector толкает: когда событие попадает в $store, каждый downstream sample или combine пересчитывается немедленно. Triggery тянет: когда событие срабатывает, обработчик читает conditions.x ровно один раз. Effector хорош для высокочастотного производного состояния (счётчики, индикаторы, «эта вьюшка всегда показывает текущие значения вот этих пяти штук»); Triggery хорош для «правила, которое запускается в момент срабатывания, без непрерывной деривации».

EffectorTriggery
Ментальная модельPush-граф stores, events, effectsPull-only обработчик читает ctx
Владение состояниемДа — $store живёт в EffectorНет — состояние остаётся в твоём сторе
Sampling и combiningFirst-class (sample, combine)Вручную в обработчике
Мемоизированные деривацииFirst-classНе предоставляется (выполняй деривацию в сторе-хосте)
Размер~10 КБ gzipЯдро ~5 КБ gzip, ~6 КБ с биндингами
ОтменаНа эффектНа обработчик: signal + стратегия конкурентности

Выбирай Effector, если… Начинаешь с чистого листа, хочешь стор и оркестрацию в одной библиотеке, и паттерны производного состояния (combine, sample) ложатся на форму твоих данных.

Выбирай Triggery, если… Уже используешь Zustand, Redux, Jotai, MobX или signals для состояния и хочешь добавить слой побочных эффектов, не переезжая на новую платформу. Адаптеры Triggery оборачивают любой из них в условия примерно в 30 строках.


MobX — мелкозернистая реактивность для observable-состояния. Компоненты автоматически подписываются на то, что читают; мутации перерендеривают ровно нужные места. Задача, в которой MobX силён — инкрементальный UI из мутабельного состояния — это не та задача, ради которой существует Triggery (запускать правило в момент срабатывания события).

MobXTriggery
Ментальная модельObservable-состояние с автотрекингом чтенийСобытие → условия → действия
Владеет состояниемДаНет
Деривацииcomputed — инкрементально, мемоизированоНе предоставляется
Побочные эффектыreaction, autorun, whenОбработчик и реакторы
Межфичная координацияКосвенная (все читают один observable)Прямая (триггер именует сценарий)
Тестируется без рендераДа (ядро MobX не зависит от фреймворков)Да

Примитивы MobX, ближайшие к Triggery, — reaction и when. Они запускаются «когда это выражение становится truthy» — та же роль, что и охранное условие внутри обработчика. Если в приложении уже 200 строк регистраций reaction(...), размазанных по сторам, модель «один файл — один триггер» обычно понятнее.

Выбирай MobX, если… Нужна библиотека состояния с автотрекингом дериваций, и твои «сценарии» в основном вида «этот observable изменился — пересчитай это». reaction хватит.

Выбирай Triggery, если… Нужны именованные сценарии — по одному файлу на каждый, — и правило должно тестироваться без поднятия observable. Адаптер @triggery/mobx оборачивает observable из MobX как условия, не перерендеривая хост.


Практические сценарии — одна задача, три инструмента

Заголовок раздела «Практические сценарии — одна задача, три инструмента»

Короткие сниппеты вверху страницы сравнивают библиотеки на одностроковом сценарии. Ниже — четыре реалистичных сценария с полным кодом. Это те, в которых Triggery стабильно короче и понятнее.

Пользователь печатает в строке поиска. Каждое нажатие отправляет запрос. Если приходит новый запрос, пока предыдущий ещё выполняется, — старый отменяется. Результаты попадают в стор независимо от того, какое нажатие их вызвало.

Triggery
export const searchTrigger = createTrigger<{
  events:  { 'search-query': string };
  actions: { setResults: SearchHit[] };
}>({
  id: 'search',
  events: ['search-query'],
  concurrency: 'take-latest',                  // 1) declare it
  async handler({ event, actions, signal }) {  // 2) signal is plumbed in
    const r = await fetch(`/api/search?q=${event.payload}`, { signal });
    if (signal.aborted) return;
    actions.setResults?.(await r.json());
  },
});
// Producer:  const fire = useEvent(searchTrigger, 'search-query');
// Reactor:   useAction(searchTrigger, 'setResults', setResults);
// Anywhere:  <input onChange={e => fire(e.target.value)} />
RxJS
fromEvent<InputEvent>(input, 'input').pipe(
  map(e => (e.target as HTMLInputElement).value),
  debounceTime(300),
  switchMap(q => from(fetch(`/api/search?q=${q}`).then(r => r.json()))),
).subscribe(setResults);
// You also wire setResults to your store, the input ref, and handle teardown
// when the component unmounts. cancellation: AbortSignal isn't built-in —
// you build it with takeUntil(unmount$) or a custom abortable wrapper.
Effector
const queryChanged   = createEvent<string>();
const searchFx       = createEffect(async (q: string) => {
  const r = await fetch(`/api/search?q=${q}`);
  return r.json();
});
const $results       = createStore<SearchHit[]>([]).on(searchFx.doneData, (_, r) => r);

sample({ clock: queryChanged, target: searchFx });
// Debouncing: use patronum's `debounce` operator: debounce({ source: queryChanged, timeout: 300, target: searchFx });
// Cancellation: searchFx aborts on every new call only if you use `abortable` from patronum
// (Effector does NOT cancel previous effects by default — easy footgun.)

Что бросается в глаза: Triggery называет стратегию прямо в конфигурации (concurrency: 'take-latest'), и signal прокинут автоматически. RxJS заставляет собирать операторы вручную; Effector требует patronum и явной обвязки abort. Одна строка против нескольких.


Открыть модалку. Сначала закрыть любую другую открытую модалку. Когда модалка закрывается — фокус возвращается на последний элемент-триггер. Когда стек схлопывается до нуля — снимается scroll-lock с body.

Triggery
export const modalTrigger = createTrigger<{
  events: { 'modal:open': ModalSpec; 'modal:close': string };
  conditions: { stack: ModalSpec[] };
  actions: { setStack: ModalSpec[]; restoreFocus: HTMLElement | null; setScrollLock: boolean };
}>({
  id: 'modal-coordinator',
  events: ['modal:open', 'modal:close'],
  required: ['stack'],
  handler({ event, conditions, actions }) {
    const stack = conditions.stack ?? [];
    if (event.name === 'modal:open') {
      actions.setStack?.([...stack, event.payload]);
      actions.setScrollLock?.(true);
    } else {
      const next = stack.filter(m => m.id !== event.payload);
      actions.setStack?.(next);
      actions.restoreFocus?.(stack[stack.length - 1]?.triggerEl ?? null);
      if (next.length === 0) actions.setScrollLock?.(false);
    }
  },
});
Reatom
const stackAtom = atom<ModalSpec[]>([], 'stack');
const openModal = action((ctx, m: ModalSpec) => {
  stackAtom(ctx, s => [...s, m]);
  scrollLockAtom(ctx, true);
}, 'openModal');
const closeModal = action((ctx, id: string) => {
  const stack = ctx.get(stackAtom);
  const next = stack.filter(m => m.id !== id);
  stackAtom(ctx, next);
  // Restore focus from the closed modal's triggerEl — but you have to keep a
  // separate map of id → triggerEl somewhere because the closed modal is gone.
  // And don't forget the scroll-lock toggle.
  if (next.length === 0) scrollLockAtom(ctx, false);
});
// All four side-effect levers (stack, scroll lock, focus, the modal map) end up
// either inside this action or spread across actions on neighbouring atoms.
Effector
const $stack        = createStore<ModalSpec[]>([]);
const modalOpened   = createEvent<ModalSpec>();
const modalClosed   = createEvent<string>();
const scrollLock    = createEvent<boolean>();
const focusRestored = createEvent<HTMLElement | null>();

$stack.on(modalOpened, (s, m) => [...s, m]).on(modalClosed, (s, id) => s.filter(m => m.id !== id));
sample({ clock: modalOpened, fn: () => true,  target: scrollLock });
sample({
  clock: modalClosed,
  source: $stack,
  fn: (s, id) => s.find(m => m.id === id)?.triggerEl ?? null,
  target: focusRestored,
});
sample({ clock: modalClosed, source: $stack, filter: s => s.length === 0, fn: () => false, target: scrollLock });

Обработчик Triggery читается сверху вниз как правило. Версия Effector корректна и элегантна, но требует трёх вызовов sample, чтобы выразить то, что Triggery делает тремя строками if/else. Reatom уплощает всё в одно действие, но теряет ветвление по типу события — благодаря которому координатор модалок легко читается.


Пользователь нажимает logout. Все кешированные запросы сбрасываются, все открытые модалки закрываются, все незавершённые загрузки отменяются, пользователя перенаправляет на /login.

Triggery
export const logoutTrigger = createTrigger<{
  events:  { 'auth:logout': void };
  actions: { dropAllQueries: void; closeAllModals: void; abortAllUploads: void; navigate: string };
}>({
  id: 'auth-logout',
  events: ['auth:logout'],
  handler({ actions }) {
    actions.abortAllUploads?.();
    actions.dropAllQueries?.();
    actions.closeAllModals?.();
    actions.navigate?.('/login');
  },
});
Redux Saga
function* logoutSaga() {
  yield takeLatest('auth:logout', function* () {
    yield call(abortAllUploads);
    yield put(dropAllQueries());
    yield put(closeAllModals());
    yield put(push('/login'));
  });
}
// Plus: rootSaga.fork(logoutSaga), all the imports for closeAllModals/dropAllQueries actions,
// and you wire each to its slice's reducer separately.
Effector
const logoutClicked   = createEvent();
const allUploadsAborted = createEffect(abortAllUploads);
sample({ clock: logoutClicked, target: allUploadsAborted });
sample({ clock: logoutClicked, target: dropAllQueriesFx });
sample({ clock: logoutClicked, target: closeAllModalsFx });
sample({ clock: logoutClicked, fn: () => '/login', target: navigateFx });

Версия Triggery — то, что большинство команд распознают как очевидный ответ. Версия Saga нормальная, но использует функцию-генератор и заставляет писать воркер как замыкание. Версия Effector повторяет sample({ clock: logoutClicked, … }) четыре раза — на трёх реакциях это терпимо; на десяти превращается в отдельную сущность, которую приходится сопровождать.


Сценарий 4 — Зависимый fetch (в стиле combineLatest)

Заголовок раздела «Сценарий 4 — Зависимый fetch (в стиле combineLatest)»

Когда установлены и userId, и period, запрашиваем статистику пользователя за этот период. Если меняется любой из них — повторяем запрос с актуальными значениями обоих. Отмена — по стратегии take-latest.

Triggery
export const statsTrigger = createTrigger<{
  events:     { 'period-changed': string; 'user-changed': string };
  conditions: { userId: string | null; period: string | null };
  required:   ['userId', 'period'];
  actions:    { setStats: Stats };
}>({
  id: 'fetch-stats',
  events: ['period-changed', 'user-changed'],
  required: ['userId', 'period'],
  concurrency: 'take-latest',
  async handler({ conditions, actions, signal }) {
    if (!conditions.userId || !conditions.period) return;
    const r = await fetch(`/api/users/${conditions.userId}/stats/${conditions.period}`, { signal });
    if (signal.aborted) return;
    actions.setStats?.(await r.json());
  },
});
// Producers fire 'user-changed' / 'period-changed'; providers register `userId` and `period`
// conditions. The required-gate makes sure nothing fetches until both are present.
RxJS
combineLatest([userId$.pipe(distinctUntilChanged()), period$.pipe(distinctUntilChanged())])
  .pipe(
    filter(([u, p]) => Boolean(u) && Boolean(p)),
    switchMap(([u, p]) => from(fetch(`/api/users/${u}/stats/${p}`).then(r => r.json()))),
  )
  .subscribe(setStats);
Effector
const $userId = createStore<string | null>(null).on(userChanged, (_, id) => id);
const $period = createStore<string | null>(null).on(periodChanged, (_, p) => p);
const fetchStats = createEffect(({ u, p }: { u: string; p: string }) => fetch(`/api/users/${u}/stats/${p}`).then(r => r.json()));

sample({
  clock: [userChanged, periodChanged],
  source: { u: $userId, p: $period },
  filter: ({ u, p }) => Boolean(u) && Boolean(p),
  fn: ({ u, p }) => ({ u: u!, p: p! }),
  target: fetchStats,
});

RxJS делает это двумя операторами и, возможно, выходит чище всего — если ты уже думаешь потоками. sample в Effector покрывает это одним объявлением, но нужно знать filter и fn и доверять non-null-ветке. Triggery сидит посередине: шлюз required — это фильтр, conditions — это последние значения, take-latest отменяет предыдущий fetch автоматически.

Выигрыш Triggery в DX: правило читается как обычный текст. «Когда меняется пользователь или период — если есть оба, делаем fetch и записываем». Никакого нового словаря; никакой цепочки операторов; никакого параметра sample с четырьмя ролями.


Как комбинировать Triggery с другой библиотекой

Заголовок раздела «Как комбинировать Triggery с другой библиотекой»

На практике редко выбирают что-то одно. Типичные пары:

ПараЧто где живёт
Redux + TriggeryRedux владеет стором и слайсами; триггеры заменяют listenerMiddleware как механизм побочных эффектов. @triggery/redux выставляет селекторы как условия.
Zustand + TriggeryТо же — селекторы Zustand как условия через @triggery/zustand.
RxJS + TriggeryRxJS отвечает за композицию потоков; .subscribe(fireEvent) соединяет потоки с триггерами.
XState + TriggeryXState владеет конечными автоматами; триггеры обрабатывают окружающие побочные эффекты и вызывают service.send(...) из useAction.
Effector + TriggeryВыбирай что-то одно как оркестратор — они слишком сильно пересекаются, чтобы использовать обе на одну роль. Либо Effector на стор и sample, Triggery — на остальное, либо наоборот.
MobX + TriggeryMobX владеет observable-состоянием; @triggery/mobx оборачивает observable как условия без отслеживания.

В каждой паре на вопрос «кто именует сценарий?» обычно отвечает Triggery: каждая другая библиотека сильна в своей основной задаче, но ни одна не даёт один файл, который читается как спецификация «когда X произошло, если Y — сделай Z».