Зачем нужен Triggery
Большинство приложений ломаются не из-за состояния. Они ломаются из-за побочных эффектов — правил вида «когда случилось это, а вон то ещё и правда — сделай эти три вещи в нужном порядке», которые сцепляют фичи между собой. Triggery — небольшая опинионированная абстракция ровно для этого слоя и больше ни для чего.
Форма задачи
Заголовок раздела «Форма задачи»Возьмём чат-приложение. По WebSocket прилетает новое сообщение. Должны произойти три вещи:
- Показать тост — если пользователь сейчас не смотрит на эту переписку.
- Сыграть короткий звук — если звук не отключён и режим «не беспокоить» выключен.
- Увеличить счётчик бейджа канала — если автор сообщения не сам текущий пользователь.
Каждое правило по отдельности крошечное, но вместе они разрастаются. Они зависят от данных, которыми владеют три разные фичи (состояние чата, настройки звука, текущий пользователь). Они производят побочные эффекты в ещё трёх фичах (слой тостов, аудиосистема, бейдж сайдбара). Само правило — это «логика сценария»: оно не принадлежит ни компоненту тоста, ни аудио, ни чату. Но где-то жить ему всё равно надо.
Обычно оно живёт вот так:
// В компоненте чата — все входы есть, ни одного из выходов.
function ChatRoom({ channelId }: Props) {
const settings = useSettings();
const currentChannel = useCurrentChannel();
const audioRef = useRef<HTMLAudioElement>(null);
const { showToast } = useToastDispatcher();
const incrementBadge = useBadgeStore(s => s.increment);
useEffect(() => {
const handler = (msg: Message) => {
if (msg.channelId === currentChannel) return;
if (settings.notifications) showToast({ title: msg.author, body: msg.text });
if (settings.sound && !settings.dnd) audioRef.current?.play();
if (msg.authorId !== currentUser.id) incrementBadge(msg.channelId);
};
socket.on('new-message', handler);
return () => socket.off('new-message', handler);
}, [settings, currentChannel, /* каждое захваченное значение */]);
}Или, что хуже, эта логика размазана по трём useEffect в трёх разных компонентах — и каждый подписан на одно и то же событие сокета из своего угла. Или, ещё хуже, всё это спрятано в одной гигантской Redux Saga, к которой никто не хочет прикасаться.
Любой из этих вариантов отвечает на два вопроса:
- «Что делает мой код?» — в конце концов, если хватит терпения, да.
- «Что делает этот сценарий, в одном месте, которое можно показать продакту?» — никогда.
Форма решения
Заголовок раздела «Форма решения»Triggery предлагает: дай сценарию имя, объяви его входы и выходы как типизированные порты, опиши правило одной функцией. Всё.
export const messageTrigger = createTrigger<{
events: { 'new-message': Message };
conditions: { settings: Settings; activeChannelId: string | null; currentUserId: string };
actions: { showToast: ToastPayload; playSound: 'beep'; incrementBadge: string };
}>({
id: 'message-received',
events: ['new-message'],
required: ['settings', 'currentUserId'],
handler({ event, conditions, actions, check }) {
if (event.payload.channelId === conditions.activeChannelId) return;
if (event.payload.authorId === conditions.currentUserId) return;
if (check.is('settings', s => s.notifications)) {
actions.showToast?.({ title: event.payload.author, body: event.payload.text });
}
if (check.is('settings', s => s.sound && !s.dnd)) {
actions.debounce(800).playSound?.('beep');
}
actions.incrementBadge?.(event.payload.channelId);
},
});Чат, аудио, бейдж и панель настроек счастливо ничего не знают друг о друге. Каждый регистрирует ровно один порт триггера — useEvent, useCondition или useAction. Файл триггера — единственный артефакт, в котором собран весь сценарий целиком. И это сорок строк, читаемых сверху вниз.
Три абстракции
Заголовок раздела «Три абстракции»В Triggery всего три примитива, и каждый соответствует одной из ролей компонента, которые ты и так уже пишешь.
События
Заголовок раздела «События»Типизированная шина сообщений, но очень скромная. Событие отправляет продьюсер (клик по кнопке, кадр WebSocket, переход роутера) с типизированной полезной нагрузкой. Несколько триггеров могут реагировать на одно событие. Несколько продьюсеров могут отправлять одно событие. Рантайму это безразлично.
В чём фишка: события проходят через планировщик (по умолчанию microtask) — поэтому всплеск событий в одном тике батчится. Отправка события никогда не вызывает ререндер продьюсера.
Условия
Заголовок раздела «Условия»Условие — это кусочек «состояния мира», который обработчик триггера может прочитать в момент срабатывания. <UserProvider> регистрирует useCondition(messageTrigger, 'currentUserId', () => user.id, [user]) и спокойно занимается своими делами. Когда событие происходит, рантайм вызывает геттер — это единственный момент, когда значение читается. Компоненты с условиями не ререндерятся из-за того, что триггер сработал. Условия работают только по pull-модели.
required перечисляет условия, без которых обработчик вообще не запускается. Весь сценарий уведомлений бессмыслен без settings — поэтому триггер указывает его в required и аккуратно пропускает запуск с записью в инспекторе, если провайдер ещё не смонтирован.
Действия
Заголовок раздела «Действия»Действие — это канал побочного эффекта. Реактор — <NotificationLayer> — пишет useAction(trigger, 'showToast', handler). Когда триггер вызывает actions.showToast(payload), запускается этот обработчик. Как и условия, действия работают по pull-модели: реактор не ререндерится.
Над действиями есть маленький, но очень полезный прокси: actions.debounce(800).playSound?.() или actions.throttle(2000).updateBadge?.(n). Таймером владеет рантайм.
Чем Triggery не является
Заголовок раздела «Чем Triggery не является»Triggery намеренно маленький. Есть несколько соседних задач, которые он не решает, и в них он не лезет.
- Не менеджер состояния. Состояние живёт в твоём любимом сторе — Zustand, Redux, Jotai, MobX, Signals или просто
useState. Условия Triggery оборачивают состояние геттером, чтобы рантайм мог его запросить. Адаптеры под Zustand/Redux/Jotai делают эту работу в 30 строках. - Не реактивный движок. Никаких инкрементальных графов, сигналов и
derived. Рантайм сам толкает события при их отправке и сам читает условия, когда обработчик их запрашивает. Effector, MobX и Signals отлично справляются с инкрементальными вычислениями; Triggery оборачивает их выходы в условия. - Не машина состояний. XState — правильный инструмент для «пользователь переходит X → Y → Z с конечным набором допустимых переходов». Triggery — правильный инструмент для «случилось событие, с учётом состояния — сделай несколько вещей».
- Не библиотека потоков. RxJS даёт операторы над временем. Triggery даёт сценарии над событиями. Их можно использовать вместе.
- Не замена Redux/Saga. RTK listenerMiddleware отлично работает внутри Redux-приложений; Triggery — это расширение, которое не требует Redux-стора.
Когда использовать Triggery
Заголовок раздела «Когда использовать Triggery»Бери Triggery, когда:
- Логика побочных эффектов пересекает границы фич. (Одно событие из фичи A переключает два состояния и затрагивает три реактора в фичах B, C, D.)
- Хочется, чтобы правило сценария жило в одном файле, который можно показать продакту.
- Пишутся тесты на бизнес-логику, а не только на UI, и хочется, чтобы обработчики триггеров были почти чистыми функциями над снимком состояния.
- Поставляешь библиотеку компонентов или платформу, которой нужно координировать эффекты в приложениях-потребителях, не привязывая их к конкретному стору.
- В одной кодовой базе используется несколько фреймворков (например, микрофронтенды React + Solid), и нужен один общий слой оркестрации.
Не стоит браться за Triggery, когда:
- Все побочные эффекты жёстко привязаны к одному компоненту и не растут («переключи класс по клику»). Простой
useEffectкороче и понятнее. - Ты уже глубоко в большой схеме XState. Обработчики Triggery могут вызывать сервисы XState при необходимости, но заменять одно другим не стоит.
- Нужна серверная оркестрация с постоянными таймерами и внешними воркерами. Это территория
@triggery/server(V2) — пока используй Temporal или BullMQ.
И как же выглядит код в итоге?
Заголовок раздела «И как же выглядит код в итоге?»Три небольших файла плюс существующие компоненты, в которые добавлен один вызов хука. Новый файл (message.trigger.ts) — единственный артефакт, чей diff объясняет изменение поведения; все остальные изменения — механическая обвязка.
Это и есть цель всей библиотеки.