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:
| Concern | Tool | Why |
|---|---|---|
| Show a toast notification | useEvent | UI concern, tied to component lifecycle |
| Navigate to another page | useEvent | The router is a UI dependency |
| Log to analytics from a layout | useEvent | Side effect owned by a mounted component |
| Queue a follow-up command | Effects plugin | Domain logic, not tied to any component |
| Call an external API in response to an event | Effects plugin | Async work that needs cancellation |
| Track recent searches after completion | Effects plugin | Bookkeeping 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
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:
| Source | Fires when |
|---|---|
effectHandler | The effect handler threw |
abortedDispatch | The handler called ctx.queue() after its run was aborted — the command is dropped |
destroyedEffects | on() 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:
| Mode | Behavior |
|---|---|
"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 |
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.signaltofetch. Without it the effect run is abandoned but the HTTP request keeps going.switchmode aborts the run, not any I/O you did not wire to the signal. - Re-throwing. An aborted
fetchrejects with anAbortError. Swallowing everything hides genuine 500s and offline failures; swallowing nothing turns every keystroke into a reported error. Checkctx.signal.abortedand 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:
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:
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 asctx.signalin the handler, and settles the superseded handle asinterrupted. - Effects cancel based on event triggers, with
mode,debounce, andcancelOn.
_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.