Commiq Docs
Usage Patterns

Effects and Cancellation

Structured side effects with concurrency modes, cancellation, and error reporting using the effects plugin.

Effects and Cancellation

The effects plugin (@naikidev/commiq-effects) manages side effects that run outside the store — API calls, queuing follow-up commands, background work — with cancellation, debouncing, and concurrency control. Combined with interruptable commands it covers most cancelable async work.

createEffects(sealedStore, options?) takes a sealed store. Effects are consumers: they observe via the event stream and dispatch via queue(). They cannot register handlers or mutate state directly.

mode defaults to "switch", not parallel. If the same event fires again while an effect run is still in flight, the in-flight run is aborted and a new one starts. If you need overlapping runs, pass mode: "parallel" explicitly. This is the opposite of the v1 default.

useEvent vs. the effects plugin

Both react to store events. The question is where the reaction belongs:

ConcernToolWhy
Show a toast notificationuseEventUI concern, tied to component lifecycle
Navigate to another pageuseEventThe router is a UI dependency
Log to analytics from a layoutuseEventSide effect owned by a mounted component
Queue a follow-up commandEffects pluginDomain logic, not tied to any component
Call an external API in response to an eventEffects pluginAsync work that needs cancellation
Track recent searches after completionEffects pluginBookkeeping independent of the UI

Rule of thumb: if the side effect must still run with no UI mounted, use the effects plugin. If it only makes sense while a specific component is on screen, use useEvent.

Basic effect

stores/order.ts
import { createCommand } from "@naikidev/commiq";
import { createEffects } from "@naikidev/commiq-effects";
import { orderStore, OrderEvent } from "./order";

const effects = createEffects(orderStore, {
  onError: (report) => {
    errorReportingService.capture(report.error, {
      source: report.source,
      event: report.event?.name,
    });
  },
});

const stopTracking = effects.on(OrderEvent.Placed, (data, ctx) => {
  ctx.queue(createCommand("analytics:track", { name: "order_placed", id: data.orderId }));
});

The handler receives the event's payload and an EffectContext with state (read-only), queue(), and signal (an AbortSignal). effects.on() returns an Unsubscribe for removing that one effect.

Error handling is not automatic

An effect handler that throws does not fail the command that emitted the event, and does not reach the store's onError. Effects have their own reporter, and if you do not install one the failure is reported to the console outside production and is invisible in production.

const effects = createEffects(store, {
  onError: (report) => reportToService(report.error, { source: report.source }),
});

EffectErrorReport is { error, source, event?, command? } where source is:

SourceFires when
effectHandlerThe effect handler threw
abortedDispatchThe handler called ctx.queue() after its run was aborted — the command is dropped
destroyedEffectson() or a dispatch happened after destroy()

abortedDispatch is the one to know about: a cancelled run that reaches a ctx.queue() call has its command discarded rather than written to the store. That is what makes switch mode safe — a superseded run cannot land stale data. It also means work after an await may silently not happen, which is correct but worth being explicit about:

effects.on(SearchEvent.Completed, async (data, ctx) => {
  const enriched = await enrich(data.query, { signal: ctx.signal });
  if (ctx.signal.aborted) return; // do not bother dispatching
  ctx.queue(createCommand("search:setEnriched", enriched));
});

A per-effect onError overrides the instance-level one:

effects.on(UploadEvent.Started, uploadHandler, {
  onError: (report) => showToast("Upload failed", { type: "error" }),
});

Concurrency modes

mode controls what happens when the event fires while a run is active:

ModeBehavior
"switch" (default)Abort the running effect and start a new one — last one wins
"parallel"Let runs overlap; all of them complete
"drop"Ignore the new event while a run is active — first one wins
"queue"Run them one after another in arrival order
stores/autocomplete.ts
effects.on(
  AutocompleteEvent.QueryChanged,
  async (data, ctx) => {
    try {
      const suggestions = await fetchSuggestions(data.query, { signal: ctx.signal });
      ctx.queue(createCommand("autocomplete:setSuggestions", suggestions));
    } catch (error) {
      if (ctx.signal.aborted) return; // superseded — not a real failure
      throw error;                     // real failure — let onError see it
    }
  },
  { mode: "switch" },
);

Two things this snippet does that are easy to skip and expensive to skip:

  • Passing ctx.signal to fetch. Without it the effect run is abandoned but the HTTP request keeps going. switch mode aborts the run, not any I/O you did not wire to the signal.
  • Re-throwing. An aborted fetch rejects with an AbortError. Swallowing everything hides genuine 500s and offline failures; swallowing nothing turns every keystroke into a reported error. Check ctx.signal.aborted and re-throw the rest.

restartOnNew still works and is deprecated: true maps to "switch", false maps to "parallel". mode wins if both are given. Prefer mode — it names all four behaviors instead of two.

Debounced effects

{ debounce: ms } delays the run. If the event fires again inside the window, the pending timer is discarded and restarted:

stores/search.ts
effects.on(
  SearchEvent.Completed,
  (data, ctx) => {
    ctx.queue(createCommand("search:addRecent", data.query));
  },
  { debounce: 200 },
);

Useful for bookkeeping that should only happen once activity settles. debounce composes with mode — debouncing decides when a run starts, mode decides what happens to a run already in progress.

Cancel on event

{ cancelOn: eventDef } aborts the running effect when a specific event fires:

stores/upload.ts
import { createEvent, createCommand } from "@naikidev/commiq";

export const UploadEvent = {
  Started: createEvent<{ fileId: string }>("upload:started"),
  Canceled: createEvent("upload:canceled"),
};

effects.on(
  UploadEvent.Started,
  async (data, ctx) => {
    try {
      await uploadFile(data.fileId, { signal: ctx.signal });
      ctx.queue(createCommand("upload:complete", { fileId: data.fileId }));
    } catch (error) {
      if (ctx.signal.aborted) {
        ctx.queue(createCommand("upload:markCanceled", { fileId: data.fileId }));
        return;
      }
      throw error;
    }
  },
  { cancelOn: UploadEvent.Canceled },
);

The ctx.queue in the aborted branch is dropped and reported as abortedDispatch — a cancelled run cannot write to the store. If cancellation needs to be recorded in state, queue that command from the handler that emitted UploadEvent.Canceled, not from the aborted effect.

Combining with interruptable commands

Interruptable commands and effects cancel on different axes:

  • Interruptable commands cancel a previous execution of the same command when a new one is queued. The store owns the AbortSignal, exposed as ctx.signal in the handler, and settles the superseded handle as interrupted.
  • Effects cancel based on event triggers, with mode, debounce, and cancelOn.
stores/search.ts
_store.addCommandHandler(
  SearchCommand.query,
  async (ctx, cmd) => {
    ctx.setState((prev) => ({ ...prev, query: cmd.data }));

    const results = await searchApi(cmd.data, { signal: ctx.signal });
    if (ctx.signal?.aborted) return;

    ctx.setState((prev) => ({ ...prev, results }));
    ctx.emit(SearchEvent.Completed, { query: cmd.data, count: results.length });
  },
  { interruptable: true },
);

effects.on(
  SearchEvent.Completed,
  (data, ctx) => {
    ctx.queue(createCommand("search:addRecent", data.query));
  },
  { debounce: 200 },
);

ctx.signal on a command context is optional (AbortSignal | undefined) — it is only present when the handler was registered interruptable. Guard with ctx.signal?.aborted.

{ rollbackOnInterrupt: true } makes the store restore the state from before an interrupted command, published as its own stateChanged. Use it when a partially applied optimistic update should not survive being superseded — see optimistic updates.

There is no loading flag in the state above. useCommandStatus(store, SearchCommand.query) reports pending from the event stream — see async loading states.

Cleanup

Effects conforms to Disposable. destroy() aborts every running effect and drops the stream subscription:

effects.destroy();

After destroy(), on() and any dispatch from an in-flight run are ignored and reported with source: "destroyedEffects". The instance cannot be revived — create a new one.

To remove a single effect, call the Unsubscribe that on() returned:

const stop = effects.on(OrderEvent.Placed, handler);
stop();

For effects scoped to a component's lifetime, create and destroy inside a useEffect:

import { useEffect } from "react";
import { createCommand } from "@naikidev/commiq";
import { createEffects } from "@naikidev/commiq-effects";

function SearchPage() {
  useEffect(() => {
    const effects = createEffects(searchStore, {
      onError: (report) => console.error(report.error),
    });

    effects.on(SearchEvent.Completed, (data, ctx) => {
      ctx.queue(createCommand("search:addRecent", data.query));
    });

    return () => effects.destroy();
  }, []);

  return <SearchForm />;
}

Under React strict mode this runs twice in development. That is fine here because the cleanup destroys the first instance — but it is the reason to prefer module-level effects for domain logic. A component-scoped effect stops working the moment the component unmounts, which is rarely what domain bookkeeping wants.

On this page