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

Встроенные триггеры

Отдельный файл *.trigger.ts — правильный дом для любого правила, которое переиспользуется по кодовой базе, принадлежит фиче или имеет смысл показать не-инженеру. Большинство правил такой церемонии заслуживают. Некоторые — нет. Модалка, которая хочет закрыться по глобальному событию 'esc:pressed'. Панель, отправляющая аналитический пинг в момент, когда конкретная страница становится видимой. Dev-only-флаг, перезапускающий feature-flow по нажатию хоткея.

Для таких случаев правильный инструмент — useInlineTrigger: хук, объявляющий триггер прямо в файле компонента, которому принадлежит побочный эффект.

src/features/CtaBanner.tsx
import { useInlineTrigger } from '@triggery/react';

export function CtaBanner() {
  useInlineTrigger<{ events: { 'cta:click': { id: string; placement: string } } }>({
    on: 'cta:click',
    do: ({ event }) => {
      analytics.track('cta_click', event.payload);
    },
  });

  return <button onClick={() => fireCta()}>Sign up</button>;
}

Три вещи:

  • Schema-generic такой же, как у createTrigger, — карты events / conditions / actions. Большинство inline-триггеров используют только events, поэтому остальные две обычно опущены.
  • on — имя события (должно совпадать с одним из ключей в events). Должно быть стабильным между рендерами.
  • do — обработчик. Принимает тот же аргумент ctx, что и обычный триггер (event, conditions, actions, check, signal, meta).

Хук работает по принципу fire-and-forget: триггер создаётся при монтировании, регистрируется в активном рантайме и удаляется при размонтировании. Пока компонент в дереве — правило живо.

  • Стабильный id. Если не передал, хук генерирует debug-id (inline:<counter>) при первом запуске и фиксирует его на время жизни компонента. Этот id появляется в записях инспектора, чтобы можно было опознать, какой inline-триггер сработал.
  • Стабильная ссылка на обработчик. Внутри хук держит ref на последний обратный вызов do. Сам объект триггера создаётся один раз; последующие перерендеры обновляют обработчик без снятия регистрации. Поэтому замыкание над локальным состоянием — нормально: запускается самое свежее замыкание.
  • Cleanup при размонтировании. При размонтировании триггер снимается с регистрации (trigger.dispose()). Любой выполняющийся async-запуск прерывается через signal.aborted = true.
  • Наследование скоупа. Если хук смонтирован внутри <TriggerScope id="…">, inline-триггер автоматически наследует этот скоуп.

Четыре самых частых случая:

Компонент не вызывает действий; он просто хочет среагировать на одно событие, уже летающее по приложению.

function PricingPage() {
  useInlineTrigger<{ events: { 'route:visible': { path: string } } }>({
    on: 'route:visible',
    do: ({ event }) => {
      if (event.payload.path === '/pricing') analytics.page('pricing');
    },
  });
  return <PricingContent />;
}

Модалка закрывает себя при смене маршрута. Правило локально для этого компонента — никакого сценария, достойного имени, нет.

function ConfirmDialog({ onClose }: Props) {
  useInlineTrigger<{ events: { 'route:change': void } }>({
    on: 'route:change',
    do: onClose,
  });
  return <Dialog>…</Dialog>;
}

Замыкание do автоматически захватывает свежий onClose через ref.

Нажми Cmd-K, отправь 'devtools:open'. Dev-only-панель реагирует на это событие, без продакшен-стоимости.

function DevToolbar() {
  const [open, setOpen] = useState(false);

  useInlineTrigger<{ events: { 'devtools:open': void } }>({
    on: 'devtools:open',
    do: () => setOpen(true),
  });

  if (!import.meta.env.DEV) return null;
  return open ? <DevPanel onClose={() => setOpen(false)} /> : null;
}

Новая библиотека, которую ты обкатываешь, отправляет событие, которое хочется перевести в триггер-style-сценарий на полдня — пока решаешь, стоит ли это полноценного .trigger.ts.

useInlineTrigger<{
  events: { 'fancy-lib:event': { kind: string; data: unknown } };
}>({
  on: 'fancy-lib:event',
  do: ({ event, signal }) => {
    if (event.payload.kind !== 'ready') return;
    if (signal.aborted) return;
    initializeIntegration(event.payload.data);
  },
});

Прожило день — выноси в полноценный триггер.

useInlineTrigger — запасной выход, не дефолт. Выноси в файл *.trigger.ts, как только верно хотя бы одно:

  • У правила есть conditions или actions. Inline-триггеры технически могут использовать обе карты, но в этот момент ты прячешь сценарную логику внутри UI-компонента. Вынеси.
  • Больше одного компонента захочет читать это правило. Как только появилась мысль «извлеку-ка» — извлекай.
  • Обработчик вырастает за ~10 строк. Inline-триггеры задуманы визуально маленькими. Большее превращает файл компонента в файл сценария под прикрытием.
  • Нужны именованные хуки. Именованные хуки опираются на отдельный модуль триггера — createNamedHooks(trigger) к inline-триггерам не применим.
  • Нужно видеть его в статическом runtime.graph(). Inline-триггеры регистрируются во время монтирования и не видны build-time-экстракторам графа.
  • Нужно тестировать без рендера React. Inline-триггеры живут внутри хука — их тестирование требует рендера host-компонента. Отдельный .trigger.ts тестируется напрямую.

Другими словами: если правилу нужно хоть что-то из «имя, узнаваемое продакт-менеджерами», «тесты», «переиспользование», «именованные хуки», «статический граф» — это правило для createTrigger, а не для useInlineTrigger.

Кастомный id для стабильных записей инспектора

Заголовок раздела «Кастомный id для стабильных записей инспектора»

По умолчанию хук авто-генерирует inline:<counter>. Два ре-монта одного компонента получают один и тот же id в пределах сессии — счётчик не сбрасывается; между сессиями id не стабилен. Если нужен осмысленный id в инспекторе — полезно, когда inline-триггер часто срабатывает и ты ищешь его в панели — передай явный:

useInlineTrigger({
  id: 'cta-banner:cta-click',
  on: 'cta:click',
  do: ({ event }) => analytics.track('cta_click', event.payload),
});

Id всё равно должны быть уникальны в пределах рантайма. Один и тот же id, смонтированный дважды, активирует поведение last-mount-wins: второй монт молча заменяет первый. Полезно для двойных монтов под React StrictMode, но в проде это значит, что два разных <CtaBanner> будут наступать друг другу на ноги — выбирай id, включающий дискриминатор инстанса компонента, либо оставляй авто-id.

  • Не авто-обнаруживает *.trigger.ts. Это работа @triggery/vite. Inline-триггеры всегда регистрируются при монтировании, точка.
  • Не принимает required, schedule, concurrency или scope в V1. Триггер работает с дефолтами активного рантайма (microtask-расписание, take-latest-параллелизм, без required, скоуп наследуется от окружающего <TriggerScope>). Если нужны эти настройки — это ещё один сигнал к выносу в полноценный createTrigger-файл.
  • Не дедуплицирует. Два компонента, вызывающих useInlineTrigger с одинаковым auto-id-паттерном в одном дереве рендера, каждый создают свой триггер. Оба сработают на совпадающее событие.

Если два компонента делят одну inline-схему, вынеси её в тип:

type CtaEvents = { events: { 'cta:click': { id: string; placement: string } } };

function CtaBanner() {
  useInlineTrigger<CtaEvents>({ on: 'cta:click', do: ({ event }) => {/* … */} });
  // …
}

function CtaFooter() {
  useInlineTrigger<CtaEvents>({ on: 'cta:click', do: ({ event }) => {/* … */} });
  // …
}

В этот момент стоит спросить себя: это всё ещё inline, или пора заводить cta.trigger.ts и именованный хук useCtaClickEvent? По правилу большого пальца — да, но общий тип — вполне валидный промежуточный шаг.

Внутри хук захватывает on на первом рендере и фиксирует. Смена on между рендерами выводит DEV-only-warning и не влияет на зарегистрированный триггер — побеждает старое имя события. Это намеренно: триггер, у которого имя события скачет между рендерами, создаёт churn регистраций, который не стоит поддерживать. Выбирай событие на месте вызова; если приходится переключаться — смонтируй два разных inline-триггера под условием.