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.

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 отвяжет их вместе с остальной временной работой.