Skip to content

Effects

An effect is for external work that finishes later: an HTTP request, storage write, worker call, timer, analytics event, or external API call.

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

An effect is callable, but that is not its main value. Its main value is making the async lifecycle visible to the model.

What Effects Expose

An effect exposes result events and state stores. started receives call params. done and failed receive params with the result or error. doneData and failData expose only the result or error. settled and finally run in both cases. pending tells whether any call is active, and inFlight stores the number of active calls.

Effect lifecycle state is published immediately when async work starts or settles. It is not hidden until the surrounding business transaction commits, because UI often needs pending and inFlight as execution state rather than domain state.

This lifecycle exception is part of the broader transaction model.

If a call is made with a signal that is already aborted, the handler never runs: the effect emits aborted and then the fail channel (failedfailDatasettled), and never emits started.

ts
searchFx.started;
searchFx.done;
searchFx.doneData;
searchFx.failed;
searchFx.fail;
searchFx.failData;
searchFx.finally;
searchFx.settled;
searchFx.abort;
searchFx.aborted;
searchFx.pending;
searchFx.inFlight;

The model can react to them like normal events. A search model can keep status, error, results, and cancellation in one place:

ts
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, result, error handling, and cancellation stay in the model instead of spreading through components. UI can read loading, requests, status, results and only call searchSubmitted or searchCancelled.

Effect variants

Use effect.variant when a model needs its own public operation, but the actual work already exists in another effect. This is common for API effects: several models can reuse the same transport handler while keeping separate pending, doneData, failData, and aborted units.

ts
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,
}));

When authorizedRequestFx(42) runs in a scope, the mapper reads token from that scope and passes the assembled params to requestFx.

A variant is a front door to the base effect, not a copy of it. Calling authorizedRequestFx calls requestFx, so the base lifecycle fires too: requestFx.started, requestFx.pending/inFlight, and requestFx.doneData/failData all account for that call. This is what makes the base effect a useful aggregate — requestFx.pending is true while any of its variants is in flight, and one reaction on requestFx.failData covers every route into the transport.

Order follows the nesting. On start the variant fires first, since it is what was called, and then the base. On settle the base finishes first and the variant right after, because the variant is waiting on the call it made.

Aborting a variant cancels the base call it created. Both effects emit aborted, the base under its own mapped params.

Scoped handler overrides apply at both levels. Overriding the base in a test replaces the work for every variant at once. Overriding the variant replaces the delegation itself, so the base is never called and its lifecycle stays silent — the one case where the two come apart.

If the call params already match the base effect, omit the mapper:

ts
const profileLoadUserFx = requestFx.variant("profileLoadUserFx");

There is no separate source declaration to write: stores are ordinary scoped values, so the mapper reads token.value directly and gets it from the calling scope.

Cancellation

Aborting an effect settles the active call immediately with the abort reason. The handler does not need to listen to signal or reject its own promise for Virentia lifecycle to finish.

searchFx.abort(reason) cancels the active calls of this effect in the current scope — calls of the same effect running in another scope are left untouched. First, aborted runs with { params, reason }; then the call finishes as a failure and goes through failData and settled. pending and inFlight update from that Virentia-level cancellation even if the original handler promise is still waiting. That is why the model above does not let the generic failData handler overwrite the cancelled status.

Effects started by an active effect inherit the parent cancellation automatically. If openSearchFx calls searchFx, aborting openSearchFx also aborts the child searchFx call with the same reason.

ts
const searchFx = effect(async (text: string) => {
  return new Promise<string[]>(() => {
    // Virentia will still settle this call when searchFx.abort() runs.
  });
});

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(() => {});

The effect handler still receives an AbortSignal. Pass it to APIs that support cancellation, such as fetch, adapter functions, worker tasks, or long-running operations, when you also want the underlying work to stop.

If you need to cancel one specific call, pass an external AbortSignal when starting it:

ts
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;
});

Temporary models also cancel effect calls created inside them when their owner is disposed.

Prefer effects over plain promise chains when other parts of the model need to know that async work started, finished, failed, or was cancelled.