Async Loading States
A standard pattern for async commands with loading, success, and error state handling.
Async Loading States
Async command handlers naturally map to three phases: loading, success, and error. Representing these consistently in the state shape makes components predictable and eliminates edge cases.
State shape
Use a status discriminant rather than separate boolean flags:
type UserState = {
id: string | null;
name: string | null;
status: "idle" | "loading" | "error";
errorMessage: string | null;
};
export const initialState: UserState = {
id: null,
name: null,
status: "idle",
errorMessage: null,
};A status enum cannot represent impossible combinations. Two boolean flags (isLoading, isError) can both be true at the same time — status cannot.
Command handler
import { createStore, createEvent, sealStore } from "@naikidev/commiq";
type User = { id: string; name: string };
export const UserEvent = {
Fetched: createEvent<User>("user:fetched"),
FetchFailed: createEvent<{ message: string }>("user:fetch-failed"),
};
const _store = createStore<UserState>(initialState);
_store.addCommandHandler<{ id: string }>("user:fetch", async (ctx, cmd) => {
ctx.setState({ ...ctx.state, status: "loading", errorMessage: null });
try {
const user = await fetchUserById(cmd.data.id);
ctx.setState({ id: user.id, name: user.name, status: "idle", errorMessage: null });
ctx.emit(UserEvent.Fetched, user);
} catch (e) {
ctx.setState({ ...ctx.state, status: "error", errorMessage: (e as Error).message });
ctx.emit(UserEvent.FetchFailed, { message: (e as Error).message });
}
});
export const userStore = sealStore(_store);The handler transitions through three states in sequence:
- Set
status: "loading"synchronously as the first line — this is immediate feedback. - On success: update the data fields and set
status: "idle". Emit a success event. - On failure: record the error message and set
status: "error". Emit a failure event.
Emitting events on both outcomes allows other stores and UI layers to react without polling state.
Selectors
Write targeted selectors to avoid unnecessary re-renders:
function UserProfile({ userId }: { userId: string }) {
const status = useSelector(userStore, (s) => s.status);
const name = useSelector(userStore, (s) => s.name);
const errorMessage = useSelector(userStore, (s) => s.errorMessage);
const queue = useQueue(userStore);
useEffect(() => {
queue(UserCommand.fetchUser(userId));
}, [userId]);
if (status === "loading") return <Spinner />;
if (status === "error") return <ErrorMessage message={errorMessage} />;
return <p>{name}</p>;
}Each useSelector call subscribes independently. A component that only reads status will not re-render when name changes. For encapsulating this into a reusable hook, see domain hooks.
Reset
Provide an explicit reset command to clear state when navigating away or when the data is no longer relevant:
_store.addCommandHandler("user:reset", (ctx) => {
ctx.setState(initialState);
});Exporting initialState alongside the store ensures the reset is consistent with the initial state definition. Avoid duplicating the initial values in the reset handler.
Multiple async operations
When a store manages several independent async operations, track each one with its own status field:
type DashboardState = {
user: { data: User | null; status: "idle" | "loading" | "error" };
orders: { data: Order[]; status: "idle" | "loading" | "error" };
};This allows components to show granular loading states — a spinner next to the orders table while the user header has already loaded.
Do not queue a second fetch command while a fetch is already in progress unless you intend to cancel the first. Commiq processes commands sequentially, so a second user:fetch queued while the first is still running will wait and then execute after the first completes.