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:
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:
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),
};
}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:
| Field | Semantics |
|---|---|
pending | true while at least one run of that command name is in flight |
error | The error from the most recent terminal event, or null |
lastCompletedAt | The 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:fetchruns share one snapshot;pendingstaystrueuntil 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. erroris last-write-wins. A failure followed by a success leaveserror: null, because the success cleared it oncommandStarted. 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:
| Need | Where 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 percentage | State |
| An error that must survive a remount | State |
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 finishesIf 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.