Реакции
Реакция — это правило модели. Она не хранит состояние, не сообщает о факте и не выполняет внешнюю работу. Ее задача — связать эти части: событие произошло, стор изменился, эффект завершился, значит модель должна выполнить правило.
Чаще всего реакция живет рядом с теми юнитами, которые связывает. Так по модели видно не только “какие данные есть”, но и “почему они меняются”.
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.
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.
reaction({
on: messageReceived,
run(message) {
messages.items = [...messages.items, message];
},
});Используйте явный on, когда payload является частью правила. Например, “пришло сообщение”, “форма отправлена”, “запрос успешно завершился”, “эффект был отменен”. Это читается лучше, чем реакция, которая просто наблюдает за состоянием и пытается угадать, что произошло.
Можно слушать несколько источников, если правило для них действительно одинаковое:
reaction({
on: [saved, cancelled],
run() {
modalOpened.value = false;
},
});Асинхронные реакции
Тело реакции может быть async. Это нужно для последовательного выполнения асинхронных шагов одного правила — дождаться эффект, затем продолжить, — а не для замены эффектов. Внешняя асинхронная работа по-прежнему это effect; async-тело лишь оркестрирует их.
Обычный способ — напрямую await-ить эффекты. Вызов эффекта внутри тела автоматически выполняется в scope реакции, а ожидание сохраняет этот scope для следующего шага — поэтому вы не передаёте scope и не оборачиваете вызов в scoped:
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:
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.
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.
reaction({
on: ticked,
name: "tick-counter",
run() {
count.value += 1;
},
});Остановка
Реакция возвращает объект со stop(). После остановки она отвязывается от зависимостей и больше не получает новые запуски.
const subscription = reaction({
on: ticked,
run() {
count.value += 1;
},
});
subscription.stop();В динамических моделях чаще не нужно вызывать stop() вручную. Создавайте такие реакции внутри owner: при dispose Virentia отвяжет их вместе с остальной временной работой.