Store File Structure
How to organize store files for clarity, testability, and scale.
Store File Structure
Commiq stores are plain TypeScript modules. No structure is required, but the layouts below hold up as a project grows.
Declare state fields readonly
Before any layout question: declare every state field readonly, and every array readonly T[].
Every state-reading surface — store.state, ctx.state, a selector's argument, StateUpdater's prev — is typed DeepReadonly<S>. If a state field is a mutable Item[], then DeepReadonly<S> gives you readonly Item[], which is not assignable back to S. Spreading ctx.state and keeping that field is a compile error:
type Bad = { items: Item[]; total: number }; // mutable
type Good = { readonly items: readonly Item[]; readonly total: number };// With Bad: TS2345 — readonly Item[] is not assignable to Item[]
ctx.setState((prev) => ({ ...prev, total: 10 }));
// With Good: fine, and so is every other spread
ctx.setState((prev) => ({ ...prev, total: 10 }));With a mutable field you must rebuild it on every update, which is easy to miss and produces an error message about variance rather than about your intent. Declaring the type readonly costs one keyword and makes both forms of setState work everywhere. Outside production the store also deep-freezes state, so the readonly types match the runtime behavior instead of merely describing an intention.
Single-file stores
For most stores, one file per domain is right. Commands, events, handlers, and the sealed export live together.
import {
createStore,
createCommandDef,
createEvent,
sealStore,
} from "@naikidev/commiq";
// ── Types ────────────────────────────────────────────────────────────────────
export type CounterState = {
readonly count: number;
};
export const initialState: CounterState = { count: 0 };
// ── Events ───────────────────────────────────────────────────────────────────
export const CounterEvent = {
Incremented: createEvent<{ amount: number }>("counter:incremented"),
Reset: createEvent<void>("counter:reset"),
};
// ── Commands ─────────────────────────────────────────────────────────────────
export const CounterCommand = {
increment: createCommandDef("counter:increment"),
incrementBy: createCommandDef<{ amount: number }>("counter:incrementBy"),
reset: createCommandDef("counter:reset"),
};
// ── Store (private) ──────────────────────────────────────────────────────────
const _store = createStore<CounterState>(initialState, {
onError: (report) => reportToService(report.error, { source: report.source }),
});
_store
.addCommandHandler(CounterCommand.increment, (ctx) => {
ctx.setState((prev) => ({ count: prev.count + 1 }));
ctx.emit(CounterEvent.Incremented, { amount: 1 });
})
.addCommandHandler(CounterCommand.incrementBy, (ctx, cmd) => {
ctx.setState((prev) => ({ count: prev.count + cmd.data.amount }));
ctx.emit(CounterEvent.Incremented, { amount: cmd.data.amount });
})
.addCommandHandler(CounterCommand.reset, (ctx) => {
ctx.setState(initialState);
ctx.emit(CounterEvent.Reset, undefined);
});
// ── Public interface ─────────────────────────────────────────────────────────
export const counterStore = sealStore(_store);The raw store stays private. The sealed store, the command definitions, the events, and initialState are the public exports.
addCommandHandler returns the store, so it chains. addEventHandler and openStream return an Unsubscribe and cannot be chained — call them as separate statements:
// Chains
_store
.addCommandHandler(CounterCommand.increment, handleIncrement)
.addCommandHandler(CounterCommand.reset, handleReset);
// Does not chain — returns Unsubscribe
const stopWatching = _store.addEventHandler(CounterEvent.Reset, handleReset);Domain folder stores
As a store grows, split it. Each file stays focused and the public surface is one index.ts.
import { createCommandDef } from "@naikidev/commiq";
export const UserCommand = {
fetch: createCommandDef<{ id: string }>("user:fetch"),
signOut: createCommandDef("user:signOut"),
};import { createEvent } from "@naikidev/commiq";
export const UserEvent = {
Fetched: createEvent<{ id: string; name: string }>("user:fetched"),
SignedOut: createEvent<void>("user:signedOut"),
};import { createStore, sealStore } from "@naikidev/commiq";
import { UserCommand } from "./commands";
import { UserEvent } from "./events";
export type UserState = {
readonly id: string | null;
readonly name: string | null;
};
export const initialState: UserState = { id: null, name: null };
const _store = createStore<UserState>(initialState);
_store
.addCommandHandler(UserCommand.fetch, async (ctx, cmd) => {
const user = await fetchUserById(cmd.data.id);
ctx.setState({ id: user.id, name: user.name });
ctx.emit(UserEvent.Fetched, user);
})
.addCommandHandler(UserCommand.signOut, (ctx) => {
ctx.setState(initialState);
ctx.emit(UserEvent.SignedOut, undefined);
});
export const userStore = sealStore(_store);Seal in store.ts, not in index.ts. Anything in the folder that needs the raw store — addEventHandler, useExtension, a devtools connect — must live in the same file, because _store is not exported.
import { useSelector, useQueue, useCommandStatus } from "@naikidev/commiq-react";
import type { DeepReadonly } from "@naikidev/commiq";
import { UserCommand } from "./commands";
import { userStore } from "./store";
import type { UserState } from "./store";
function selectUser(state: DeepReadonly<UserState>) {
return state.name;
}
export function useUser() {
const name = useSelector(userStore, selectUser);
const { pending, error } = useCommandStatus(userStore, UserCommand.fetch);
const queue = useQueue(userStore);
return {
name,
loading: pending,
error,
fetch: (id: string) => queue(UserCommand.fetch, { id }),
signOut: () => queue(UserCommand.signOut),
};
}Define selectors at module scope, not inline. A selector declared inside the component is a new function every render; useSelector handles that, but a module-level function is free and makes the return-value memoization straightforward to reason about.
export { UserCommand } from "./commands";
export { UserEvent } from "./events";
export { userStore, initialState } from "./store";
export type { UserState } from "./store";
export { useUser } from "./hooks";Consumers import from ./features/user, never from a file inside it.
Wiring that spans stores
Cross-store wiring does not belong in either store. Give it its own module:
bus.ts imports the sealed stores, connects them to an event bus, and routes events to commands. It is the only file that knows the whole flow — see multi-store coordination.
Plugin setup — devtools, persist, OpenTelemetry — belongs in its own module for the same reason. See composing plugins.
Store factories for testability
Exporting a factory alongside the singleton lets tests get a fresh store, and lets the store take injected dependencies:
import { createStore, sealStore } from "@naikidev/commiq";
import type { StoreOptions } from "@naikidev/commiq";
import { CounterCommand } from "./commands";
export type CounterState = { readonly count: number };
export const initialState: CounterState = { count: 0 };
export function createCounterStore(options?: StoreOptions) {
const _store = createStore<CounterState>(initialState, options);
_store.addCommandHandler(CounterCommand.increment, (ctx) => {
ctx.setState((prev) => ({ count: prev.count + 1 }));
});
return sealStore(_store);
}
export const counterStore = createCounterStore();Accepting StoreOptions is what makes the factory useful in tests: they pass their own onError to assert on failures and to keep expected errors out of the output. See testing and dependency injection.
Rules of thumb
- Declare state readonly. Not stylistic — mutable fields make
setStatespreads fail to compile. - Export the sealed store. The sealed facade is
state,queue,flush,suspend,openStream,closeStream. It narrows what consumers can reach; it is not a security boundary and does not add freezing of its own. - Keep the raw store private. Prefix it
_and never export it. Anything needing it lives in the same file. - Group by domain, not by type. Avoid a global
events/orcommands/folder. - Namespace names.
"user:fetch", not"fetch". Names appear in devtools, in error reports, and inuseCommandStatus. - Export
initialState. Reset handlers reference it instead of duplicating the values. - Install
onErrorat creation. It is the only channel that sees stream-listener and extension failures, and in production the default reporter is silent. - One
withDefer(store)/withHistory(store)per store. They bind to the store passed as their first argument and report an error if shared.