Skip to content

Реакции

Реакция — это правило модели. Она не хранит состояние, не сообщает о факте и не выполняет внешнюю работу. Ее задача — связать эти части: событие произошло, стор изменился, эффект завершился, значит модель должна выполнить правило.

Чаще всего реакция живет рядом с теми юнитами, которые связывает. Так по модели видно не только “какие данные есть”, но и “почему они меняются”.

ts
const queryChanged = event<string>();
const searchSubmitted = event<void>();

const query = store("");
const results = reactive({ items: [] as string[] });

const searchFx = effect(async (text: string) => {
  const response = await fetch(`/api/search?q=${encodeURIComponent(text)}`);
  return (await response.json()) as string[];
});

reaction({
  on: queryChanged,
  run(text) {
    query.value = text;
  },
});

reaction({
  on: searchSubmitted,
  run() {
    void searchFx(query.value);
  },
});

reaction({
  on: searchFx.doneData,
  run(items) {
    results.items = items;
  },
});

Здесь события остаются маленькими: они только называют произошедшее. Эффект занимается запросом. Сторы помнят состояние. Реакции описывают причинность между ними.

Автоматические зависимости

По умолчанию удобно начинать с реакции без on. Внутри такой реакции вы читаете сторы, а Virentia запоминает, от каких сторов зависит правило. Когда один из прочитанных сторов меняется, реакция запускается снова в том же scope.

ts
const query = store("");
const online = store(true);
const canSearch = store(false);

reaction(() => {
  canSearch.value = online.value && query.value.trim().length > 2;
});

Этот режим особенно полезен, когда зависимости проще выразить чтением состояния, а не списком источников. Если внутри есть ветвление, список зависимостей обновляется после каждого запуска: реакция будет слушать именно те сторы, которые были прочитаны в актуальной ветке.

По умолчанию реакция глобальна: она перезапускается, когда прочитанный ею стор меняется в любом scope, и каждый запуск читает значение из того scope, где произошло изменение. Это подходит для обычного случая, когда правило одинаково во всех scopes. Чтобы привязать реакцию к конкретным scopes и изолировать её зависимости по scope, передайте scope: явно — см. Реакции в scope. Привязка никогда не выводится из scope, который случайно был активен в момент создания реакции.

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

Явный on

on нужен, когда важна причина запуска: конкретное событие, эффект или юнит жизненного цикла эффекта. В этом режиме реакция не запускается при создании. Она срабатывает только от указанного юнита и получает его payload — событие отдаёт свой payload, эффект-источник отдаёт параметры вызова, а юнит жизненного цикла эффекта (done, failed, aborted, …) отдаёт значение этого юнита.

ts
reaction({
  on: messageReceived,
  run(message) {
    messages.items = [...messages.items, message];
  },
});

Используйте явный on, когда payload является частью правила. Например, “пришло сообщение”, “форма отправлена”, “запрос успешно завершился”, “эффект был отменен”. Это читается лучше, чем реакция, которая просто наблюдает за состоянием и пытается угадать, что произошло.

Можно слушать несколько источников, если правило для них действительно одинаковое:

ts
reaction({
  on: [saved, cancelled],
  run() {
    modalOpened.value = false;
  },
});

Асинхронные реакции

Тело реакции может быть async. Это нужно для последовательного выполнения асинхронных шагов одного правила — дождаться эффект, затем продолжить, — а не для замены эффектов. Внешняя асинхронная работа по-прежнему это effect; async-тело лишь оркестрирует их.

Обычный способ — напрямую await-ить эффекты. Вызов эффекта внутри тела автоматически выполняется в scope реакции, а ожидание сохраняет этот scope для следующего шага — поэтому вы не передаёте scope и не оборачиваете вызов в scoped:

ts
reaction({
  on: checkoutRequested,
  async run(order, { signal }) {
    await reserveStockFx(order);
    signal.throwIfAborted();
    await chargeFx(order);
  },
});

Тело получает { scope, signal }:

  • signal — это AbortSignal, который прерывается, когда та же реакция срабатывает снова в том же scope или когда реакция остановлена. Это даёт семантику отмены предыдущего запуска (switch): новый запуск вытесняет ещё выполняющийся старый. Разделяйте шаги через signal.throwIfAborted().
  • scope — scope, в котором сработала реакция. Он редко нужен — прямые вызовы эффектов уже выполняются в нём. Тянитесь к нему только когда осознанно хотите запустить что-то в нём, например scoped(scope, () => fx()), чтобы дождаться целого графа ниже по потоку, а не одного эффекта. (Ambient scope сохраняется через ожидаемый эффект, но не через сырой await fetch(), поэтому внешнюю асинхронность оборачивайте в эффект.)

Всё async-тело дожидается промисом scoped на границе, которая запустила реакцию, включая любой эффект, запущенный без await.

Трекинг сквозь await

Автоматическая реакция тоже может быть async. Каждый прочитанный ею стор — зависимость, включая чтения после await:

ts
reaction(async () => {
  const id = currentId.value; // отслеживается
  await loadDetailsFx(); // ожидание эффекта
  preview.value = details.value[id]; // details тоже отслеживается
});

Это работает только когда вы await-ите эффекты (или scoped), потому что эффекты восстанавливают scope для продолжения. Сырой await fetch() отвязывает от scope, поэтому внешняя асинхронность должна идти через эффект. Отслеживаются только собственные прямые чтения реакции — чтение computed внутри тела добавляет сам computed, а не его внутренние зависимости. Каждый запуск отслеживается изолированно, поэтому пересекающиеся async-запуски не смешивают зависимости; побеждает последний запуск.

Реакции в scope

Реакция глобальна, пока вы не передадите scope. Передайте его, чтобы привязать реакцию к одному scope — или к списку scopes, — тогда она выполняется только когда её источник срабатывает в этих scopes, а её автоматически отслеживаемые зависимости изолируются по scope.

ts
reaction({
  on: ticked,
  scope: appScope,
  run() {
    count.value += 1;
  },
});

Используйте это для двух вещей:

  • Обвязка, принадлежащая одному запущенному экземпляру — логгер, мост синхронизации, склейка с devtools, — а не модели в целом.
  • Правило, зависимости которого различаются между scopes. Автоматическая реакция, читающая разные сторы в разных scopes (ветка по per-scope флагу), как глобальная делила бы один набор зависимостей и перестала бы отслеживать ветку, выбранную другим scope. Привязка через scope: даёт каждому scope свой набор зависимостей, поэтому каждый scope остаётся точным.

Привязка всегда явная. Реакция, созданная внутри scoped(appScope, …), не привязывается к appScope неявно — ambient scope это глобал, на который модель не должна полагаться. Передавайте scope: appScope, когда действительно это имеете в виду.

Метаданные для инспектора

name и key — необязательные подсказки для инспектора. name задаёт подпись узла реакции; key помечает её как keyed-узел, чтобы инспектор различал реакции с одинаковым именем в разных scopes.

ts
reaction({
  on: ticked,
  name: "tick-counter",
  run() {
    count.value += 1;
  },
});

Остановка

Реакция возвращает объект со stop(). После остановки она отвязывается от зависимостей и больше не получает новые запуски.

ts
const subscription = reaction({
  on: ticked,
  run() {
    count.value += 1;
  },
});

subscription.stop();

В динамических моделях чаще не нужно вызывать stop() вручную. Создавайте такие реакции внутри owner: при dispose Virentia отвяжет их вместе с остальной временной работой.

Когда реакция бросает исключение

Непойманная ошибка в реакции ведёт себя так же, как в обычном асинхронном JavaScript: выражения после броска не выполняются, а отказ доходит до того, кто ждал триггер. Конкретно Virentia:

  1. останавливает эту ветку — ничего ниже упавшей реакции не выполняется;
  2. сообщает об отказе — никогда молча;
  3. всё равно выполняет независимые ветки того же обновления;
  4. отклоняет триггер для того, кто его ожидает (AggregateError, если упало несколько веток).

Асинхронная реакция подчиняется ровно тем же правилам, что и синхронная.

ts
reaction({ name: "connectSocket", on: authorized, run: () => { throw new Error("handshake"); } });
reaction({ on: authorized, run: () => analytics.track("authorized") }); // всё равно выполнится

// Обновление без ожидания: отказ логируется, работа продолжается.
scoped(appScope, () => { token.value = "abc"; });

// Ожидаемый триггер отклоняется с этой же ошибкой.
await scoped(appScope, () => submitted()); // бросит Error("handshake")

Запись из синхронного кода никто не ожидает, поэтому отказ рапортуется, но не бросается. Он никогда не превращается в unhandled rejection — упавшее правило не может уронить Node-процесс.

То же сдерживание действует для колбэков store.subscribe(...): один бросивший подписчик не лишает остальных уведомления и не останавливает распространение.

Как читать отчёт об отказе

Отчёт по умолчанию рассчитан на голую консоль, без подключённых devtools:

[virentia] reaction failed: reaction "connectSocket"
  declared at: /src/features/auth/model.ts:42:3
  scope: scope:1
  propagation path: store "$token" → computed "$authorized" → reaction "connectSocket"
  this branch stopped here; independent branches of the same update still ran
  the update as a whole fails, so an awaited trigger will reject with this error
  Error: handshake
      at run (/src/features/auth/model.ts:44:11)

declared at снимается при создании реакции, поэтому даже анонимная реакция указывает на реальный исходник. Именование юнитов (store(0, undefined, { name: "$token" }), reaction({ name: "connectSocket", … })) превращает путь из идентификаторов в имена — стоит делать для правил, которые вы рассчитываете отлаживать.

Отправка отказов в другое место

setErrorReporter меняет адресата — креш-репортер, структурный логгер, тестовый шпион. Он получает структуру, а не только текст, поэтому ничего не нужно разбирать обратно из строки:

ts
import { setErrorReporter } from "@virentia/core";

setErrorReporter((failure) => {
  Sentry.captureException(failure.error, {
    tags: { unit: failure.unit, kind: failure.kind },
    extra: { path: failure.path, declaredAt: failure.declaredAt, scope: failure.scope },
  });
});

В failure.message лежит готовый отчёт, если нужно просто залогировать. null возвращает консольный вывод по умолчанию. Репортер, который сам бросил, не роняет обновление — отчёт уходит в запасной консольный вывод, поэтому отказ остаётся виден.