Commiq Docs
Usage Patterns

Event-Driven Side Effects

Trigger notifications, navigation, and analytics from store events without coupling logic to views.

Event-Driven Side Effects

Not every reaction to a store event is a state change. Showing a toast, navigating after login, logging an analytics event — these are UI concerns and should not live in the store.

useEvent(store, eventDef, handler) subscribes a component to one event type and runs a callback when it fires, without adding transient flags to state. The subscription is removed on unmount.

The problem with state flags

// Avoid
type FormState = {
  readonly dirty: boolean;
  readonly justSaved: boolean; // exists only to trigger a toast
};

A flag like justSaved needs a second command to clear it, shows up in every selector, and is still unreliable: a component that mounts after the flag was set sees a stale true and fires the toast late.

The pattern

Emit a semantic event from the handler:

features/form/store.ts
import { createCommandDef, createEvent } from "@naikidev/commiq";

type FormState = {
  readonly dirty: boolean;
};

export const FormCommand = {
  save: createCommandDef<{ title: string; body: string }>("form:save"),
};

export const FormEvent = {
  Saved: createEvent<{ id: string }>("form:saved"),
  SaveFailed: createEvent<{ message: string }>("form:saveFailed"),
};

_store.addCommandHandler(FormCommand.save, async (ctx, cmd) => {
  try {
    const result = await saveFormData(cmd.data);
    ctx.setState((prev) => ({ ...prev, dirty: false }));
    ctx.emit(FormEvent.Saved, { id: result.id });
  } catch (error) {
    ctx.emit(FormEvent.SaveFailed, {
      message: error instanceof Error ? error.message : "Could not save",
    });
    throw error;
  }
});

The throw error matters: without it the handler swallows the failure, the command settles as handled, and nothing reaches onError. The emitted event tells the UI what happened; re-throwing tells the store the command failed. Both are needed — see error handling.

Subscribe in the component that owns the side effect:

pages/FormPage.tsx
import { useEvent } from "@naikidev/commiq-react";
import { formStore, FormEvent } from "../features/form";

function FormPage() {
  useEvent(formStore, FormEvent.Saved, (event) => {
    showToast(`Saved (${event.data.id})`);
  });

  useEvent(formStore, FormEvent.SaveFailed, (event) => {
    showToast(event.data.message, { type: "error" });
  });

  return <FormEditor />;
}

event.data is typed from the EventDef, so no cast is needed. The store emits what happened; the component decides what to do. Neither knows about the other.

useEvent only sees events emitted while the component is mounted. That is the point — a toast should not fire for something that happened before the page opened — but it also means it is not a way to observe past state. Read state with useSelector, and per-command status with useCommandStatus.

Errors that are not your event

For the failure of a specific command, you often do not need an event of your own:

const { error } = useCommandStatus(formStore, FormCommand.save);

That derives from the builtin commandHandlingError, so a handler that simply lets its error escape needs no custom failure event at all. Emit SaveFailed when the payload matters — a field-level validation map, a retry token — not merely to signal that something failed.

pages/LoginPage.tsx
import { useNavigate } from "react-router-dom";
import { useEvent } from "@naikidev/commiq-react";
import { authStore, AuthEvent } from "../features/auth";

function LoginPage() {
  const navigate = useNavigate();

  useEvent(authStore, AuthEvent.SignedIn, () => {
    navigate("/dashboard");
  });

  return <LoginForm />;
}

The router is a UI dependency. The auth store emits AuthEvent.SignedIn and knows nothing about React Router.

The handler is read from a ref updated in a layout effect, so a callback closing over navigate always sees the current one — the subscription does not need to be torn down and rebuilt when the callback identity changes, and you do not need useCallback here.

Analytics

Centralizing analytics in a layout keeps tracking out of both the store and individual features:

AppShell.tsx
function AppShell() {
  useEvent(orderStore, OrderEvent.Placed, (event) => {
    analytics.track("order_placed", {
      orderId: event.data.orderId,
      total: event.data.total,
    });
  });

  useEvent(authStore, AuthEvent.SignedOut, () => {
    analytics.track("signed_out");
  });

  return <Outlet />;
}

Subscriptions clean up when the shell unmounts. That is the tradeoff: analytics stops when the component does. If an event must always be tracked regardless of what is mounted, it belongs in an effect, not here.

useEvent vs. the effects plugin

ReactionTool
Toast, modal, focus changeuseEvent
NavigationuseEvent
Analytics owned by a mounted layoutuseEvent
Analytics that must never be missedEffects plugin
Queue a follow-up commandEffects plugin
Call an API with cancellationEffects plugin

The test: if the reaction must still happen with no UI mounted, use the effects plugin. If it only makes sense while a specific component is on screen, use useEvent.

useEvent also cannot queue commands with cancellation, debouncing, or concurrency control. It is a plain callback — one subscription, no lifecycle beyond mount and unmount.

useEvent handlers that throw are not reported as store errors. The stream listener is isolated and the error is re-thrown asynchronously, so it surfaces as a window error rather than through StoreOptions.onError. Handle failures inside the callback — a showToast that throws should not be your only signal.

State vs. events

ReactionBelongs in stateBelongs in useEvent
Loading spinnerNo — useCommandStatusNo
Inline field errorYesNo
Transient toastNoYes
Navigate to another pageNoYes
Analytics eventNoYes
Disabled button after submitNo — useCommandStatusNo
Data the page rendersYesNo

State is what is currently true about the domain. Events are what just happened. Two things that used to require state fields — loading and a per-command error — now come from useCommandStatus, so the "belongs in state" column is shorter than it was.

Observing many events at once

useEvent takes one EventDef. For a debug panel or an audit log that wants everything, use useStream:

import { useStream } from "@naikidev/commiq-react";

function EventLog() {
  const [entries, setEntries] = useState<string[]>([]);

  useStream(orderStore, (event) => {
    setEntries((prev) => [...prev.slice(-100), `${event.name} @ ${event.timestamp}`]);
  });

  return <ul>{entries.map((entry, index) => <li key={index}>{entry}</li>)}</ul>;
}

This receives every builtin event too, including one stateChanged per setState, so it is noisy by design. Cap the buffer. See event stream.

Where to put useEvent

Put it in the component that owns the side effect — a page, a layout, or a notification provider. Avoid burying it in a leaf.

// Good: the page owns the navigation
function CheckoutPage() {
  const navigate = useNavigate();

  useEvent(checkoutStore, CheckoutEvent.Completed, () => {
    navigate("/order-confirmation");
  });

  return <CheckoutForm />;
}

// Avoid: a leaf navigating is hard to trace, and the subscription
// disappears if the button unmounts mid-checkout
function SubmitButton() {
  useEvent(checkoutStore, CheckoutEvent.Completed, () => {
    navigate("/order-confirmation");
  });

  return <button>Place order</button>;
}

The second form is not just stylistically worse: a button that unmounts while the command is in flight misses the event entirely, and the navigation silently never happens.

On this page