Анатомия триггера
createTrigger — единственный конструктор в публичном API. Каждый триггер выглядит так:
const myTrigger = createTrigger<Schema>({
id: 'unique-id',
events: ['event-a', 'event-b'],
required: ['conditionA', 'conditionB'],
schedule: 'microtask', // optional
concurrency: 'take-latest', // optional
scope: 'feature-x', // optional
handler({ event, conditions, actions, check, signal, meta }) {
// …
},
});Восемь полей, три из них опциональные. Разберём каждое.
Обобщённый параметр схемы
Заголовок раздела «Обобщённый параметр схемы»<Schema> — единственный источник правды о наборе портов триггера. У него три опциональные карты:
type Schema = {
events?: Record<string, unknown>; // payload type per event name
conditions?: Record<string, unknown>; // value type per condition name
actions?: Record<string, unknown>; // payload type per action name
};Триггеры без событий запрещены на этапе компиляции — без события триггер никогда не запустится. А вот без условий или без действий — нормально и часто полезно: у многих аналитических триггеров одно событие и одно действие.
Пустой payload пишется как void:
events: {
'app:ready': void; // no payload
'message-received': { author: string };
};id (обязательно)
Заголовок раздела «id (обязательно)»Уникальная строка, идентифицирующая триггер в рантайме. Её используют три вещи:
- Инспектор индексирует прогоны по
id. - DevTools подписывают действия как
triggery/<id>/fire. graph()(CLI / API рантайма) делает по одной ноде на каждыйid.
Id должен быть строковым литералом. Правило no-dynamic-id из @triggery/eslint-plugin это контролирует — динамические id ломают devtools, индексацию инспектора и статический экстрактор графа.
// ✓
createTrigger<Schema>({ id: 'message-received', /* … */ });
// ✗ — rejected by ESLint, breaks devtools.
const dynamic = `trigger-${Date.now()}`;
createTrigger<Schema>({ id: dynamic, /* … */ });Id — это существительные в kebab-case, описывающие сценарий, а не событие. message-received читается лучше, чем on-new-message-show-toast или new-message. Имя, совпадающее с реальным продуктовым понятием, ещё и делает инспектор читаемым.
events (обязательно)
Заголовок раздела «events (обязательно)»Readonly-массив имён событий из схемы. Рантайм индексирует триггеры по имени события — рассматриваются только те триггеры, у которых в events указано сработавшее событие.
events: ['new-message', 'urgent-message'],Если у триггера одно событие — укажи его. Если он реагирует на несколько — перечисли все: обработчик увидит event.name и event.payload, типизированные как объединение всех перечисленных событий.
Этот список — единственное, против чего eslint-правило exhaustive-conditions делает кросс-проверку. Если в файле вызывается useEvent(messageTrigger, 'foo'), но 'foo' нет в events, получишь lint-ошибку.
required (опционально)
Заголовок раздела «required (опционально)»Список имён условий, которые должны присутствовать, чтобы обработчик запустился.
required: ['user', 'settings'],Поведение:
- Перед запуском обработчика рантайм проверяет, что у каждого required-условия есть хотя бы один зарегистрированный геттер.
- Если хоть одного нет →
inspector.recordSkip('missing-required'), обработчик не вызывается. - Если все есть → обработчик запускается как обычно.
required — это шлюз, позволяющий безопасно собирать сценарии. Если <UserProvider> ещё не размещён, никто ничего не запускает; как только он смонтируется, следующее событие запустит обработчик.
schedule (опционально, по умолчанию 'microtask')
Заголовок раздела «schedule (опционально, по умолчанию 'microtask')»Управляет тем, когда отправленное событие диспатчится обработчикам.
| Значение | Поведение | Сценарий |
|---|---|---|
'microtask' (по умолчанию) | Все события, отправленные в одном тике, батчатся и диспатчатся в следующем microtask. | Почти всё. Хорошо сочетается с React. |
'sync' | Диспатч сразу, до возврата из fireEvent. | Тесты, горячие пути, где надо проверить побочный эффект в том же кадре вызова. |
Будущие планировщики ('animation-frame', 'idle', 'priority') есть в roadmap как опциональные варианты, а не как умолчания.
concurrency (опционально, по умолчанию 'take-latest')
Заголовок раздела «concurrency (опционально, по умолчанию 'take-latest')»Управляет поведением async обработчиков, когда триггер срабатывает снова, пока предыдущий прогон ещё в полёте.
| Стратегия | Поведение |
|---|---|
'take-latest' (по умолчанию) | Новый прогон отменяет предыдущий через signal.aborted = true. За проверку отвечает сам предыдущий прогон. |
'take-every' | Каждый прогон идёт независимо. Без отмены. |
'take-first' / 'exhaust' | Новые срабатывания игнорируются, пока один в полёте. |
'queue' | Новые срабатывания встают в очередь; сериализуются. |
'sync' | То же, что take-every; нужен как маркер для документации синхронных обработчиков. |
Это важно только для async обработчиков. У синхронных обработчиков нет понятия «в полёте». См. Стратегии конкурентности.
scope (опционально)
Заголовок раздела «scope (опционально)»Привязывает триггер к <TriggerScope id="...">. Внутри этого скоупа триггеру видны только регистрации, сделанные через useCondition / useAction в том же скоупе. Без скоупа триггер видит все глобальные регистрации.
createTrigger<Schema>({ id: 'message-received', scope: 'chat', /* … */ });<TriggerScope id="chat">
<ChatRoom /> {/* useCondition('user', …) — only this trigger sees it */}
<NotificationLayer />
</TriggerScope>
<TriggerScope id="settings">
{/* this scope's registrations are invisible to the 'chat'-scoped trigger */}
</TriggerScope>Сценарий — изоляция фичи: два экземпляра одной триггер-логики работают в двух разных панелях чата, у каждого свой стейт, и их условия не наступают друг другу на пятки. См. Скоупы.
handler (обязательно)
Заголовок раздела «handler (обязательно)»Функция, которая запускается, когда происходит подходящее событие и все required-условия на месте. Аргумент — контекст триггера (ctx) с шестью полями:
handler({ event, conditions, actions, check, signal, meta }) {
// event — { name, payload } discriminated union of all listed events
// conditions — { [name]: value | undefined } typed map of registered values
// actions — { [name]: (payload) => void } typed map of registered actions
// + actions.debounce(ms).foo(), actions.throttle(ms).foo(), etc.
// check — { is, all, any } typed predicate helpers over conditions
// signal — AbortSignal that flips when this run is superseded
// meta — { triggerId, runId, cascadeId, scheduledAt, … }
}Полный разбор каждого поля — в Обработчиках.
Обработчик может быть async. Тогда он получает AbortSignal, который рантайм переключает, когда новый прогон вытесняет этот (под take-latest) или когда рантайм / скоуп уничтожается. Передавай его в свои fetch-вызовы.
async handler({ event, signal, actions }) {
const res = await fetch(event.payload.url, { signal });
if (signal.aborted) return;
actions.show?.(await res.json());
}Что возвращает createTrigger
Заголовок раздела «Что возвращает createTrigger»Объект Trigger<Schema> с небольшим API:
trigger.id; // the same string you passed in
trigger.enable(); // re-enable a disabled trigger
trigger.disable(); // skip future events (records 'disabled' in inspector)
trigger.isEnabled(); // boolean
trigger.inspect(); // current snapshot — most recent run, run count, …
trigger.dispose(); // unregister from the runtime; usually never called
trigger.namedHooks(); // → use the createNamedHooks() helper insteadК большинству из этого ты никогда не прикоснёшься — dispose нужен в тестах, disable — для фича-флагов.
Где живёт триггер
Заголовок раздела «Где живёт триггер»Договорённость — один триггер на файл, с суффиксом .trigger.ts. Помести его в src/triggers/ или положи рядом с фичей, к которой он относится:
src/
├── features/
│ ├── chat/
│ │ ├── Chat.tsx
│ │ └── chat.trigger.ts ← here
│ └── notifications/
│ ├── NotificationLayer.tsx
│ └── notifications.trigger.ts
└── triggers/ ← or here, for cross-feature scenarios
└── analytics.trigger.tsАвтообнаружение через @triggery/vite подхватывает каждый *.trigger.ts и импортирует его на старте. С ним руками никогда не приходится писать import './triggers/message.trigger'.