Commiq Docs
Usage Patterns

Async Loading States

Track pending and error state for async commands with useCommandStatus instead of hand-rolled status fields.

Async Loading States

An async command has three observable phases: running, succeeded, failed. In v1 the only way to expose them to a component was to put a status or loading field in the state and set it by hand at the top and bottom of every handler. That is no longer necessary.

useCommandStatus(source, name | def) derives the phase from the store's own event stream and returns { pending, error, lastCompletedAt }. The state shape holds domain data only.

Before and after

// v1: three fields per async operation, maintained by hand
type UserState = {
  users: User[];
  loading: boolean;
  errorMessage: string | null;
};
// v2: the state holds users
type UserState = {
  readonly users: readonly User[];
};

The hand-rolled fields also existed to work around a bug: useSelector did not observe intermediate setState calls, so a loading: true set at the top of a handler and cleared at the bottom could be missed entirely. That is fixed — stateChanged now fires once per setState and useSelector sees every one of them. The workaround is no longer load-bearing in either direction.

The pattern

The handler does the work and nothing else:

stores/users/store.ts
import { createStore, createCommandDef, createEvent, sealStore } from "@naikidev/commiq";

export type User = { id: string; name: string };

type UserState = {
  readonly users: readonly User[];
};

export const initialState: UserState = { users: [] };

export const UserCommand = {
  fetch: createCommandDef("user:fetch"),
  clear: createCommandDef("user:clear"),
};

export const UserEvent = {
  Fetched: createEvent<{ count: number }>("user:fetched"),
};

const _store = createStore<UserState>(initialState);

_store
  .addCommandHandler(UserCommand.fetch, async (ctx) => {
    const users = await fetchUsers();
    ctx.setState((prev) => ({ users: [...prev.users, ...users] }));
    ctx.emit(UserEvent.Fetched, { count: users.length });
  })
  .addCommandHandler(UserCommand.clear, (ctx) => {
    ctx.setState(initialState);
  });

export const userStore = sealStore(_store);

There is no try/catch. If fetchUsers() rejects, the error escapes the handler, which is what makes the command fail — the store reports it through onError, publishes commandHandlingError, settles the handle as failed, and useCommandStatus picks it up as error. Catching it here would report success.

The domain hook composes reading, dispatching, and status:

stores/users/hooks.ts
import { useSelector, useQueue, useCommandStatus } from "@naikidev/commiq-react";
import type { DeepReadonly } from "@naikidev/commiq";
import { userStore, UserCommand } from "./store";
import type { UserState } from "./store";

function selectUsers(state: DeepReadonly<UserState>) {
  return state.users;
}

function toMessage(error: unknown): string | null {
  if (error === null || error === undefined) return null;
  return error instanceof Error ? error.message : String(error);
}

export function useUsers() {
  const users = useSelector(userStore, selectUsers);
  const { pending, error } = useCommandStatus(userStore, UserCommand.fetch);
  const queue = useQueue(userStore);

  return {
    users,
    loading: pending,
    errorMessage: toMessage(error),
    fetch: () => queue(UserCommand.fetch),
    clear: () => queue(UserCommand.clear),
  };
}
pages/UsersPage.tsx
function UsersPage() {
  const { users, loading, errorMessage, fetch } = useUsers();

  return (
    <div>
      <button onClick={fetch} disabled={loading}>
        {loading ? "Loading…" : "Fetch users"}
      </button>
      {errorMessage && <p role="alert">{errorMessage}</p>}
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

What useCommandStatus actually tracks

It filters the stream for events carrying a command whose name matches, and counts in-flight runs by correlationId:

FieldSemantics
pendingtrue while at least one run of that command name is in flight
errorThe error from the most recent terminal event, or null
lastCompletedAtThe timestamp of the most recent terminal event, or null

Terminal events are commandHandled, commandHandlingError, commandInterrupted, and invalidCommand. pending becomes true on commandStarted and clears error.

Things worth knowing before you rely on it:

  • It is keyed by command name, not by instance. Two concurrent user:fetch runs share one snapshot; pending stays true until both settle. If you need per-item status — a spinner on one row of a list — put that in state, keyed by the item id.
  • error is last-write-wins. A failure followed by a success leaves error: null, because the success cleared it on commandStarted. It is not an error log.
  • It starts empty. The snapshot is { pending: false, error: null, lastCompletedAt: null } until the first matching event arrives. A command dispatched before the component mounted is not retroactively visible.
  • It costs a stream subscription per call. Fine for a handful; do not call it in a list row rendered a thousand times.

When state is still the right place

useCommandStatus covers "is this command running and did it fail". Anything else belongs in state:

NeedWhere it goes
Is this command running?useCommandStatus
Did the last run fail, and with what?useCommandStatus
Which specific rows are saving?State, keyed by id
Has this data ever loaded, vs. loaded and empty?State — an idle/loaded discriminant
Progress percentageState
An error that must survive a remountState

For the "loaded but empty" distinction, a discriminant in state is clearer than inferring it from lastCompletedAt:

type UserState = {
  readonly users: readonly User[];
  readonly loaded: boolean;
};

Set loaded: true in the handler on success. users.length === 0 && loaded then means "no users", while !loaded means "not fetched yet" — useCommandStatus cannot tell those apart.

Awaiting a command directly

Outside React, queue() returns a CommandHandle. Await it for the outcome of that one dispatch:

const result = await userStore.queue(UserCommand.fetch);

if (result.status === "failed") {
  console.error(result.error);
}

The handle never rejects — the outcome is in result.status. See error handling.

Sequential processing

Commands are processed one at a time. A second user:fetch queued while the first is still awaiting will wait for it and then run.

userStore.queue(UserCommand.fetch);
userStore.queue(UserCommand.fetch); // runs after the first finishes

If you want the second dispatch to replace the first rather than follow it, register the handler as interruptable:

_store.addCommandHandler(
  SearchCommand.query,
  async (ctx, cmd) => {
    const results = await searchApi(cmd.data, { signal: ctx.signal });
    if (ctx.signal?.aborted) return;
    ctx.setState((prev) => ({ ...prev, results }));
  },
  { interruptable: true },
);

The superseded run's handle settles as interrupted, and useCommandStatus treats that as terminal. Add { rollbackOnInterrupt: true } if a partially applied state change should be undone when the command is superseded.

Multiple independent operations

Each command gets its own status hook — no per-operation fields in state:

function Dashboard() {
  const userStatus = useCommandStatus(dashboardStore, DashboardCommand.loadUser);
  const orderStatus = useCommandStatus(dashboardStore, DashboardCommand.loadOrders);

  return (
    <>
      {userStatus.pending ? <HeaderSkeleton /> : <Header />}
      {orderStatus.pending ? <TableSkeleton /> : <OrderTable />}
    </>
  );
}

The two hooks are independent because they filter on different command names. See domain hooks for wrapping this up per feature.

On this page