Commiq Docs
Usage Patterns

Error Handling and Recovery

Observe command failures through onError, command handles, and builtin error events, and build retries on top.

Error Handling and Recovery

Nothing thrown inside a command or event handler propagates out of queue() or flush(). Wrapping await store.flush() in a try/catch will never catch a handler failure, and queue() returns a promise that never rejects. There are three ways to observe a failure, and none of them is a try/catch around the dispatch site:

  1. StoreOptions.onError — one callback that sees every failure from every source
  2. The CommandHandle returned by queue()await it for the result of that command
  3. Builtin error eventscommandHandlingError, eventHandlingError, unhandledError, invalidCommand

The store swallows nothing internally: every failure is routed to at least one of those channels. But it also never lets a failure escape the queue, because a rejected promise nobody awaited would become an unhandled rejection and a thrown error mid-queue would strand every command behind it.

onError: the one channel that sees everything

Pass onError when creating the store. It receives a StoreErrorReport:

type StoreErrorReport = {
  error: unknown;
  source: StoreErrorSource;
  command?: Command;
  event?: StoreEvent;
};
stores/app.ts
import { createStore } from "@naikidev/commiq";
import type { StoreErrorReport } from "@naikidev/commiq";

function reportStoreError(report: StoreErrorReport): void {
  errorReportingService.capture(report.error, {
    source: report.source,
    command: report.command?.name,
    event: report.event?.name,
    correlationId: report.command?.correlationId ?? report.event?.correlationId,
  });
}

const _store = createStore<AppState>(initialState, { onError: reportStoreError });

source is one of:

SourceFires when
commandHandlerA command handler threw
eventHandlerAn event handler threw
streamListenerAn openStream listener threw
contextExtensionAn extension's command/event/afterCommand/afterEvent/destroy hook threw
disposedContextA handler called setState or emit after its command settled
queueProcessorThe queue loop itself failed
duplicateHandleraddCommandHandler replaced an existing handler for the same name
destroyedStoreSomething was queued or registered on a destroyed store
suspendedQueueA suspend() gate was held past suspendWarningMs

Outside production the default reporter logs to the console. In production (NODE_ENV === "production") the default is silent — if you want production visibility you must pass onError explicitly.

onError is the right place for centralized monitoring because it is the only channel that sees streamListener, contextExtension, and disposedContext failures. Those have no event handler of their own to fail into; they are additionally published as unhandledError, but nothing else surfaces them.

Command handles: the result of one command

queue() returns a CommandHandle — a Promise<CommandResult> that also carries command and correlationId. Awaiting it tells you how that specific command ended:

type CommandResult = {
  status: "handled" | "failed" | "interrupted" | "invalid" | "discarded";
  command: Command;
  error?: unknown;
};
const result = await store.queue(SyncCommand.push, { endpoint: "/api/sync" });

if (result.status === "failed") {
  console.error("sync failed", result.error);
}
StatusMeaning
handledThe handler resolved without throwing
failedThe handler threw; error carries what it threw
interruptedAn interruptable command was aborted by a newer instance
invalidNo handler is registered for that command name
discardedThe store was destroyed before the command ran

The handle never rejects, so an ignored handle can never become an unhandled rejection. That also means void store.queue(...) is safe and is the normal form in event handlers and UI callbacks.

A handle settles when that command settles. Events emitted by the handler are dispatched to event handlers afterwards, so if you need the whole cascade to finish — including commands queued by event handlers — use await store.flush() instead.

flush() throws synchronously if called from inside a command or event handler. Inside a handler, await the handle from queue() instead.

Builtin error events

Use these when the reaction belongs to the domain — updating state, queuing a retry — rather than to monitoring.

stores/diagnostics.ts
import { BuiltinEvent, matchEvent } from "@naikidev/commiq";

const unsubscribe = store.openStream((event) => {
  if (matchEvent(event, BuiltinEvent.CommandHandlingError)) {
    const { command, error } = event.data;
    console.error(`command "${command.name}" failed:`, error);
  }

  if (matchEvent(event, BuiltinEvent.EventHandlingError)) {
    console.error(`event "${event.data.event.name}" handler failed:`, event.data.error);
  }

  if (matchEvent(event, BuiltinEvent.UnhandledError)) {
    console.error(`unhandled ${event.data.source} error:`, event.data.error);
  }

  if (matchEvent(event, BuiltinEvent.InvalidCommand)) {
    console.warn(`no handler registered for "${event.data.command.name}"`);
  }
});

openStream returns an Unsubscribe. Keep it if the listener is not meant to live as long as the store.

matchEvent narrows event.data to the definition's payload type, so event.data.command and event.data.error are typed without a cast.

unhandledError covers exactly the sources that have no event channel of their own — stream listeners, extension hooks, disposed contexts, and the queue processor. A command handler that throws produces commandHandlingError, not unhandledError. Listening only for unhandledError will miss every handler failure.

InvalidCommand

A command queued with no registered handler emits invalidCommand and settles its handle as invalid. This catches typos, missing registrations, and commands sent to the wrong store. Using createCommandDef turns most of these into compile errors instead, since the same definition object is used to register the handler and to dispatch.

Errors that belong in state

onError and the builtin events are for observability. A failure the user needs to see belongs in state, set by the handler that failed:

stores/sync.ts
type SyncState = {
  readonly synced: boolean;
  readonly errorMessage: string | null;
};

_store.addCommandHandler(SyncCommand.push, async (ctx, cmd) => {
  try {
    await pushToServer(cmd.data.endpoint);
    ctx.setState((prev) => ({ ...prev, synced: true, errorMessage: null }));
  } catch (error) {
    ctx.setState((prev) => ({
      ...prev,
      errorMessage: error instanceof Error ? error.message : "Sync failed",
    }));
    ctx.emit(SyncEvent.Failed, { endpoint: cmd.data.endpoint, attempt: 1 });
  }
});

A handler that catches its own error and records it in state does not produce commandHandlingError, and its handle settles as handled — the command did what it was asked to do. Only let the error escape the handler if "the command failed" is the truth you want the rest of the system to see.

In React, per-command pending and error state does not need to be stored at all — useCommandStatus reads it from the event stream.

Retry pattern

An event handler can re-queue a failed command. Track the attempt count in the command payload so the retry terminates:

stores/sync.ts
import { createCommandDef, createEvent } from "@naikidev/commiq";

const MAX_ATTEMPTS = 3;

export const SyncCommand = {
  push: createCommandDef<{ endpoint: string; attempt: number }>("sync:push"),
  setError: createCommandDef<{ message: string }>("sync:setError"),
};

export const SyncEvent = {
  Failed: createEvent<{ endpoint: string; attempt: number }>("sync:failed"),
};

_store.addCommandHandler(SyncCommand.push, async (ctx, cmd) => {
  try {
    await pushToServer(cmd.data.endpoint);
    ctx.setState((prev) => ({ ...prev, synced: true }));
  } catch {
    ctx.emit(SyncEvent.Failed, cmd.data);
  }
});

_store.addEventHandler(SyncEvent.Failed, (ctx, event) => {
  const { endpoint, attempt } = event.data;

  if (attempt < MAX_ATTEMPTS) {
    ctx.queue(SyncCommand.push, { endpoint, attempt: attempt + 1 });
    return;
  }

  ctx.queue(SyncCommand.setError, {
    message: `Sync failed after ${MAX_ATTEMPTS} attempts`,
  });
});

Always cap retries. An event handler that unconditionally re-queues a failing command loops forever, and because event handlers run inside the queue loop, the loop is tight enough to lock the tab. flush() will never resolve.

Event handlers receive an EventContext with state and queue only — no setState. That is deliberate: state changes flow through commands so the audit trail stays intact. To change state in reaction to an event, queue a command.

The retry flow:

The command fails

The handler catches its own error and emits SyncEvent.Failed carrying the current attempt number.

The event handler decides

Below the cap, it queues the same command with an incremented attempt. The new command's causedBy links to the event, so devtools shows the full retry chain under one getChain(correlationId) query.

Final failure

At the cap, it queues a command that records the error in state for the UI to render.

Adding a delay between attempts belongs in an effect rather than a handler — effects support debounce and an AbortSignal, and a setTimeout inside a command handler holds the queue for its whole duration.

Surfacing errors to the UI

useEvent runs a callback when an event fires, without adding a transient flag to state:

pages/SyncPage.tsx
import { useEvent } from "@naikidev/commiq-react";
import { syncStore, SyncEvent } from "../stores/sync";

const MAX_ATTEMPTS = 3;

function SyncPage() {
  useEvent(syncStore, SyncEvent.Failed, (event) => {
    if (event.data.attempt < MAX_ATTEMPTS) return;
    showToast(`Sync to ${event.data.endpoint} failed`, { type: "error" });
  });

  return <SyncStatus />;
}

For the failure of one specific command, useCommandStatus is more direct — it exposes error without any event of your own:

const { pending, error } = useCommandStatus(syncStore, SyncCommand.push);

See event-driven side effects for the wider pattern.

Testing failures

StoreOptions.onError is the cleanest assertion point in tests, because it is synchronous and sees every source:

sync.test.ts
import { expect, test, vi } from "vitest";
import { createStore } from "@naikidev/commiq";

test("a failing push reports through onError", async () => {
  const onError = vi.fn();
  const store = createStore<SyncState>(initialState, { onError });
  store.addCommandHandler(SyncCommand.push, () => {
    throw new Error("network down");
  });

  const result = await store.queue(SyncCommand.push, {
    endpoint: "/api",
    attempt: 1,
  });

  expect(result.status).toBe("failed");
  expect(result.error).toEqual(new Error("network down"));
  expect(onError).toHaveBeenCalledWith(
    expect.objectContaining({ source: "commandHandler" }),
  );
});

Passing an onError spy also keeps expected failures out of the test output, which the default console reporter would otherwise print. See testing.

On this page