Эффекты
Эффект нужен для внешней работы, которая завершается позже: HTTP-запрос, запись в storage, worker, таймер, аналитика, обращение к внешнему API.
const searchFx = effect(async (text: string, { signal }) => {
const response = await fetch(`/api/search?q=${text}`, { signal });
return (await response.json()) as string[];
});Эффект можно вызвать как функцию, но его главная польза не в этом. Он делает жизненный цикл асинхронной работы видимым для модели.
Жизненный цикл эффекта
У эффекта есть события результата и сторы состояния. started получает параметры вызова. done и failed получают параметры вместе с результатом или ошибкой. doneData и failData дают только результат или ошибку. settled и finally срабатывают в обоих случаях. pending показывает, есть ли активная работа, а inFlight хранит количество активных вызовов.
Состояние жизненного цикла эффекта публикуется сразу при старте и завершении асинхронной работы. Оно не прячется до коммита окружающей бизнес-транзакции, потому что для UI pending и inFlight чаще являются состоянием исполнения, а не доменным состоянием.
Это исключение жизненного цикла описано подробнее в общей модели транзакций.
searchFx.started;
searchFx.done;
searchFx.doneData;
searchFx.failed;
searchFx.fail;
searchFx.failData;
searchFx.finally;
searchFx.settled;
searchFx.abort;
searchFx.aborted;
searchFx.pending;
searchFx.inFlight;Модель может реагировать на них так же, как на обычные события. Например, поисковая модель может держать статус, ошибку, результаты и отмену в одном месте:
import { effect, event, reaction, store } from "@virentia/core";
const queryChanged = event<string>();
const searchSubmitted = event<void>();
const searchCancelled = event<void>();
const query = store("");
const results = store<string[]>([]);
const errorMessage = store<string | null>(null);
const status = store<"idle" | "loading" | "ready" | "failed" | "cancelled">("idle");
const searchFx = effect<string, string[], Error>(async (text, { signal }) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(text)}`, { signal });
if (!response.ok) {
throw new Error("Search failed");
}
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.started,
run() {
status.value = "loading";
errorMessage.value = null;
},
});
reaction({
on: searchFx.doneData,
run(items) {
results.value = items;
status.value = "ready";
},
});
reaction({
on: searchFx.failData,
run(error) {
if (status.value === "cancelled") return;
status.value = "failed";
errorMessage.value = error.message;
},
});
reaction({
on: searchFx.aborted,
run() {
status.value = "cancelled";
},
});
reaction({
on: searchCancelled,
run() {
void searchFx.abort(new Error("Search cancelled"));
},
});
export const searchModel = {
errorMessage,
loading: searchFx.pending,
query,
queryChanged,
requests: searchFx.inFlight,
results,
searchCancelled,
searchSubmitted,
status,
};Так loading, результат, ошибка и отмена остаются частью модели, а не расползаются по компонентам. UI может читать loading, requests, status, results и просто вызывать searchSubmitted или searchCancelled.
Варианты эффектов
effect.variant нужен, когда модели требуется своя публичная операция, но сама работа уже описана в другом эффекте. Это частый случай для API-эффектов: несколько моделей могут использовать один transport handler, но держать отдельные pending, doneData, failData и aborted.
import { effect, store } from "@virentia/core";
const token = store("");
const requestFx = effect(async (params: { id: number; token: string }, { signal }) => {
const response = await fetch(`/api/items/${params.id}`, {
headers: { Authorization: `Bearer ${params.token}` },
signal,
});
return response.json();
});
const authorizedRequestFx = requestFx.variant("authorizedRequestFx", (id: number) => ({
id,
token: token.value,
}));Когда authorizedRequestFx(42) вызывается в scope, mapper читает token именно из этого scope и передает собранные params в обработчик requestFx.
Жизненный цикл принадлежит варианту. Вызов authorizedRequestFx не вызывает requestFx.doneData и не переводит requestFx.pending в true. При этом scoped handler override базового эффекта сохраняется: в тесте можно заменить requestFx один раз, и все его варианты будут использовать этот handler.
Если параметры вызова уже совпадают с базовым эффектом, mapper не нужен:
const profileLoadUserFx = requestFx.variant("profileLoadUserFx");attach остается доступным для совместимости с кодом, которому удобны source и mapParams, но в новом коде Virentia обычно лучше использовать variant: сторы являются обычными scoped-значениями, поэтому token.value читается яснее, чем отдельный список источников.
Отмена
Отмена эффекта сразу завершает активный вызов с переданной причиной. Handler не обязан слушать signal или сам reject-ить promise, чтобы жизненный цикл Virentia завершился.
searchFx.abort(reason) отменяет активные вызовы этого эффекта. Сначала сработает aborted с { params, reason }, а сам вызов завершится ошибкой и пройдет через failData и settled. pending и inFlight обновятся от этой отмены на уровне Virentia, даже если исходный promise handler-а все еще ждет. Поэтому в модели выше общий обработчик failData не перетирает статус cancelled.
Эффекты, запущенные активным эффектом, автоматически наследуют отмену родителя. Если openSearchFx вызывает searchFx, отмена openSearchFx также отменит дочерний вызов searchFx с той же причиной.
const searchFx = effect(async (text: string) => {
return new Promise<string[]>(() => {
// Virentia все равно завершит этот вызов, когда выполнится searchFx.abort().
});
});
const openSearchFx = effect(async (text: string) => {
return searchFx(text);
});
await scoped(appScope, () => {
const promise = openSearchFx("virentia");
void openSearchFx.abort(new Error("Search closed"));
return promise;
}).catch(() => {});Handler эффекта по-прежнему получает AbortSignal. Передавайте его в API, которое умеет отменяться: fetch, свои adapter-функции, worker-задачи или долгие операции, если нужно остановить еще и внешнюю работу.
Если нужно отменить один конкретный вызов, передайте внешний AbortSignal при запуске:
const cancelReason = new Error("Search cancelled");
const controller = new AbortController();
const promise = scoped(appScope, () =>
searchFx("virentia", {
signal: controller.signal,
}),
);
controller.abort(cancelReason);
await promise.catch((error) => {
if (error !== cancelReason) throw error;
});Временные модели также отменяют созданные внутри вызовы эффекта через dispose владельца.
Используйте эффекты вместо обычных promise-цепочек, когда другим частям модели важно знать, что async-работа началась, завершилась, упала или была отменена.