Действия
Действие — это типизированный канал побочного эффекта: имя + тип payload. Компонент-реактор регистрирует исполнителя под этим именем через useAction; когда обработчик запускается и вызывает actions.<name>(payload), исполнитель срабатывает.
Действия — это то, через что правило, выраженное в триггере, превращается в наблюдаемое изменение в мире: появление тоста, проигрывание звука, старт fetch. Реактору не нужно знать, кто решил его вызвать — только что он делает, когда его вызвали.
Объявляем действие
Заголовок раздела «Объявляем действие»Действия живут в карте actions внутри схемы. Каждая запись отображает имя на тип payload, который получит исполнитель:
Payload void объявляет действие без аргумента: actions.beep?.().
Регистрируем исполнителя
Заголовок раздела «Регистрируем исполнителя»Реакторы используют useAction(trigger, name, handler). Обработчик получает payload, типизированный ровно так, как объявлено:
Как и у useCondition, регистрация pull-only: реактор не перерендеривается, когда триггер срабатывает. У рантайма просто есть указатель на свежее замыкание, и он зовёт его, когда обработчик попросит.
Хук держит свежий обработчик в ref — оборачивать тело в useCallback не нужно. Каждый перерендер прозрачно подменяет замыкание; сама регистрация остаётся стабильной.
Вызываем действия из обработчика
Заголовок раздела «Вызываем действия из обработчика»Каждый элемент ctx.actions типизирован как ((payload) => void) | undefined. undefined тут честный: исполнитель может быть не зарегистрирован (реактор не смонтирован или живёт в другом скоупе). Опциональное ?. — это эквивалент в месте вызова для «нормально, если никто не слушает»:
Вызов незарегистрированного действия — no-op, не ошибка. Это правильное умолчание для оркестрации: правило не должно знать, смонтирована ли аудио-фича на данной странице.
Если нужна семантика «должно быть зарегистрировано» — пиши проверку сам в духе required: if (!actions.beep) { /* fallback, log, … */ }. Встроенного поля requiredActions в V1 нет.
Прокси действий — debounce / throttle / defer
Заголовок раздела «Прокси действий — debounce / throttle / defer»ctx.actions — это прокси с маленьким композируемым API поверх. Три встроенных тайминг-обёртки:
debounce(ms) — выкидывает промежуточные вызовы. Каждый вызов перезапускает таймер ms; срабатывает только payload последнего вызова, после того как триггер замолкает на ms. Полезно для «один beep на пачку сообщений».
throttle(ms) — leading edge: первый вызов срабатывает сразу, последующие вызовы в течение ms отбрасываются. Полезно для «тикни бейдж максимум раз в две секунды, даже если приехало 50 сообщений». (Trailing-edge вариант приедет в V1.1.)
defer(ms) — вызов один раз через ms, безусловно. Каждый вызов планирует независимый таймер; они не схлопываются. Полезно для «отправь аналитику через 100 мс, чтобы пользователь, мгновенно закрывший страницу, её не запустил».
Рантайм владеет состоянием таймеров на каждый триггер. Когда триггер уничтожается, каждый незавершённый таймер отменяется. Никаких утёкших дескрипторов setTimeout, никаких исполнителей, запускающихся после размонтирования своего реактора.
См. Debouncing & throttling для правил отмены и контракта очистки таймеров.
Sync vs async исполнители
Заголовок раздела «Sync vs async исполнители»Исполнитель может вернуть void или Promise<void>. Рантайм обрабатывает оба:
Для async-исполнителей рантайм записывает resolve / reject через хуки middleware onActionEnd / onError — твой trace-middleware увидит реальные длительности. Если нужно прервать выполняющегося исполнителя, когда его триггер вытеснен, — комбинируй действия с signal обработчика. См. Отмена для паттерна.
Обработчик не ждёт actions.<name>(...) автоматически — для обработчика действия fire-and-forget. Если два действия должны идти по порядку, await’ь их явно с маленьким хелпером — или смоделируй второе действие как собственное событие в follow-up-триггере.
Конкурентность — что происходит при быстрых вызовах
Заголовок раздела «Конкурентность — что происходит при быстрых вызовах»Когда один и тот же обработчик срабатывает дважды быстро, стратегия конкурентности триггера решает, что произойдёт с обработчиком — take-latest прервёт предыдущий прогон, queue сериализует и т. д. Это настройка уровня триггера, не уровня действия. См. Стратегии параллелизма для таблицы.
А исполнитель? Он отрабатывает один раз на каждый вызов actions.foo(...). Если обработчик зовёт его три раза за прогон, исполнитель отработает три раза подряд. Используй debounce / throttle, если хочешь меньше срабатываний; используй очередь внутри исполнителя, если нужна сериализация на уровне побочного эффекта. Рантайм за тебя гадать не будет.
Распространённое сокращение для «запускай это по порядку, никогда не два параллельно» — это queue concurrency самого триггера плюс синхронное действие, которое делает реальную работу. Обработчик идёт сериализованно; действие синхронно выполняется внутри него.
Владение по правилу last-write-wins
Заголовок раздела «Владение по правилу last-write-wins»Как и условия, каждая пара (trigger, actionName) имеет один слот. Самая свежая runtime.registerAction запись побеждает; при размонтировании слот очищается (предыдущая регистрация не восстанавливается — v0.10 убрала stack-based fallback).
Новый action-channel API (trigger.action(name).subscribe(cb)) — предпочтительный путь подключать реакторы в v0.10: он аддитивный (срабатывает каждый subscriber), и useAction в React/Solid/Vue уже использует его под капотом. runtime.registerAction напрямую — только когда нужна явная single-handler ownership.
В DEV рантайм выдаёт одно предупреждение на пару (triggerId, actionName), когда приходит вторая живая registerAction запись:
Две причины, почему так нормально:
- Оверрайды first-class. Модалка смонтировалась и хочет перехватить
showToast? Монтируй оверрайд, он побеждает; предыдущий обработчик пропадёт при unmount (re-render восстановит). - Тесты простые. Монтируешь тестовый реактор; он побеждает; ассертишь, что он получил payload; teardown.
Если нужно, чтобы оба обработчика запустились (один тост, один лог), используй action-channel API: trigger.action('showToast').subscribe(cb1) + .subscribe(cb2) — оба сработают на каждый emit. registerAction остаётся в библиотеке как путь для single-handler ownership.
См. Владение для полного разбора.
Действия в скоупах
Заголовок раздела «Действия в скоупах»Как и условия, действия по умолчанию регистрируются глобально. Оберни поддерево в <TriggerScope id="...">, и действия, зарегистрированные внутри, станут видны только тем триггерам, у которых scope совпадает:
Это типобезопасная альтернатива «context-aware actions». См. Скоупы.
Общие паттерны
Заголовок раздела «Общие паттерны»Действие = повелительный глагол. showToast, playSound, incrementBadge. Реактор делает вещь. Избегай имён в стиле «compute» — это уже условия.
Не читай состояние внутри действия. Действия — это побочные эффекты, а не чистые трансформации. Если для решения «что делать» нужно состояние — смоделируй его условием, которое прочтёт обработчик, и передай результат в payload действия. Обработчик должен оставаться единственным местом с кросс-фичной логикой решений.
Одно действие на один побочный эффект. Не объединяй showToast + logError + incrementBadge в одно действие notifyAll. Их должен компоновать триггер. Гранулированные действия делают инспектор читаемым, а реакторы — тестируемыми.
Не зови действия снаружи обработчика. Действия существуют как порт прогона триггера, с прицепленными triggerId, runId, трекингом middleware и записями в инспекторе. Вызов из onClick кнопки обходит всё это. Если кнопка «делает X» — отправь событие; триггер решит, что вызвать.