Async Commands
Async command handlers, sequential queue processing, and reporting pending and error state.
Async Commands
Command handlers can be async — useful for API calls, timers, or any asynchronous work.
Key behavior: the store processes commands sequentially. When a handler is async, the queue waits for it before picking up the next command. That guarantees ordering, and it means a slow handler holds everything behind it.
Example: fetching users
Store
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, {
onError: (report) =>
console.error(`[users] ${report.source}`, report.command?.name, report.error),
});
_store
.addCommandHandler(UserCommand.fetch, async (ctx) => {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(`Request failed with ${response.status}`);
}
const users = (await response.json()) as User[];
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);Three things worth noting:
- State fields are
readonly. Every state-reading surface isDeepReadonly<S>; a mutableUser[]would make{ ...prev }fail to compile. See store file structure. - There is no
loadingorerrorfield, and no try/catch. Letting the error escape is what marks the command as failed. Catching it and recording a message would report success. !response.okis thrown explicitly.fetchdoes not reject on a 4xx or 5xx, so without this check the handler would happily parse an error page as JSON.
React component
useCommandStatus derives pending and error from the event stream, so neither lives in state:
import { useSelector, useQueue, useCommandStatus, useEvent } from "@naikidev/commiq-react";
import { userStore, UserCommand, UserEvent } from "./store";
function toMessage(error: unknown): string | null {
if (error === null || error === undefined) return null;
return error instanceof Error ? error.message : String(error);
}
export function UsersPage() {
const users = useSelector(userStore, (state) => state.users);
const { pending, error } = useCommandStatus(userStore, UserCommand.fetch);
const queue = useQueue(userStore);
const [log, setLog] = useState<string[]>([]);
useEvent(userStore, UserEvent.Fetched, (event) => {
setLog((prev) => [...prev, `Fetched ${event.data.count} users`]);
});
const handleFetch = () => {
queue(UserCommand.fetch);
};
const errorMessage = toMessage(error);
return (
<div>
<button onClick={handleFetch} disabled={pending}>
{pending ? "Loading…" : "Fetch users"}
</button>
{errorMessage && <p role="alert">{errorMessage}</p>}
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}queue(UserCommand.fetch) returns a CommandHandle that is discarded here. That is safe — the handle never rejects, so an ignored one can never become an unhandled rejection.
Awaiting a command
Outside a component, await the handle for the outcome of that one dispatch:
const result = await userStore.queue(UserCommand.fetch);
if (result.status === "failed") {
console.error(result.error);
}The handle settles when that command settles. Events it emitted are dispatched to event handlers afterwards, so use await userStore.flush() when you need the whole cascade — including commands queued by event handlers — to finish.
Sequential processing
userStore.queue(UserCommand.fetch);
userStore.queue(UserCommand.fetch); // starts after the first finishes
await userStore.flush();Two dispatches produce two requests, one after the other. If the second should replace the first, 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 passing ctx.signal to fetch cancels the request itself. ctx.signal is AbortSignal | undefined — it is only present when the handler was registered interruptable, hence the ?..
Important notes
- Sequential processing. The queue waits for each handler's promise. A handler that never resolves stalls the store, and
flush()never resolves either. ctx.stateis a live getter. It reflects the latest state even after anawait, including changes from commands that ran in between. The updater form,setState((prev) => ...), makes that dependency explicit and is the safer default after anawait.- Nothing propagates out of
queue()orflush(). Neither ever rejects. Observe failures through the handle'sstatus,StoreOptions.onError, or thecommandHandlingErrorevent. A try/catch aroundawait flush()will never fire. stateChangedfires once persetState, not once per command.useSelectorsees each one, so intermediate state is observable.flush()throws synchronously if called inside a handler. Await the handle fromqueue()instead.
See error handling for the full failure model and async loading states for what useCommandStatus does and does not cover.